Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/src/allowed-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ const root = Tree({ value: 2, children: [leaf] });
root.children[0]; // Tree
```

`Rec` is erased: the value holds a `Tree`, the constructor takes a `Tree`, and `patch` and the
`Rec` is erased. The value holds a `Tree`, the constructor takes a `Tree`, and `patch` and the
companion's members work as they do for any other type. It exists for the declaration alone.

Without it the declaration compiles and the first use of the type fails. Resolving `Tree` would need
Expand Down
4 changes: 2 additions & 2 deletions docs/src/caveats.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ Persistence helpers can create the same hole.
JSON and returns it as the type inferred from its initial value. Store a `PayloadOf<User>`, then
seal it after reading.

Seal at the boundary, because the two sides deploy separately: the value was sealed by whichever
Seal at the boundary, because the two sides deploy separately. The value was sealed by whichever
build the server is running, and that seal may be older than yours.

## Generic object utilities can bypass readonly and sealing
Expand Down Expand Up @@ -103,7 +103,7 @@ payload to `User` or `User.seal`.

A `__proto__` key survives. It is a legal JSON key, and round-tripping JSON takes priority, so
sealing keeps it as an own property rather than dropping data. That is inert inside a value, but not
in code that merges a payload with `Object.assign` or a recursive merge: there, assigning the key
in code that merges a payload with `Object.assign` or a recursive merge. There, assigning the key
sets a prototype instead of copying it. Sanitize untrusted input yourself.

## Deeply nested payloads can overflow the stack
Expand Down
4 changes: 2 additions & 2 deletions docs/src/custom-constructors.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ const User = Val.companion<User>().implSeal((input: object, seal): Result<User>
});
```

The parameter takes `object` or `Record<string, unknown>`, not `unknown`: a seal takes the payload,
The parameter takes `object` or `Record<string, unknown>`, not `unknown`. A seal takes the payload,
not a wire format.

The schema runs on every derivation, not just the first parse. Reject unknown keys yourself. A patch
Expand Down Expand Up @@ -197,7 +197,7 @@ const result = Age.seal(input); // Result<Age>
guarantees that it is validated and normalized wherever it is passed, so downstream code does not
need to repeat either step. The seals in this chapter put
[**“parse, don't validate”**](https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate/)
into practice: they return a validated, canonical Val instead of returning facts about the input.
into practice. They return a validated, canonical Val instead of returning facts about the input.
Parsing includes validation, but preserves its result in a more precise type. In Takuto Wada's
words,
[parse, don't **(just)** validate](https://speakerdeck.com/twada/growing-reliable-code-php-conference-fukuoka-2025?slide=101):
Expand Down
25 changes: 13 additions & 12 deletions docs/src/enums.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,10 @@ another name, which `companion-mismatch` reports.

The constructor writes the tag. It does not take one, and it deep-copies its payload like any other
constructor. The enum itself takes a payload that already carries the tag, typed
`SealedPayload<Shape>`: it reads the tag, selects that variant's companion, and passes the payload
`SealedPayload<Shape>`. It reads the tag, selects that variant's companion, and passes the payload
to its seal.

A variant is a Val: it patches, it compares, and it nests. A patch cannot reach the tag, so it
A variant is a Val. It patches, it compares, and it nests. A patch cannot reach the tag, so it
cannot switch variants.

```ts
Expand All @@ -49,7 +49,7 @@ type Style = Enum<"Style", { Solid: { width: number }; Dashed: { gap: number } }
type Card = Enum<"Card", { Plain: { w: number }; Framed: { w: number; style: Style } }>;
```

A nested variant is a patch boundary like any nested Val: replace it with one the constructor built,
A nested variant is a patch boundary like any nested Val. Replace it with one the constructor built,
rather than merging into it.

Define two variants at least. One variant is a Val, and TypeScript loses the alias for a union of
Expand Down Expand Up @@ -78,7 +78,7 @@ adding one to the declaration fails here rather than falling through at run time
to hard-code it.

Use `match` to split on the tag. For a condition inside a variant, a pattern matching library such
as ts-pattern fits: `_tag` is real data, so `.with({ _tag: "Circle" }, …)` already works.
as ts-pattern fits. `_tag` is real data, so `.with({ _tag: "Circle" }, …)` already works.

## Common shape for every variant

Expand Down Expand Up @@ -128,8 +128,8 @@ Shape.Circle.diameter(Shape.Circle({ r: 2 })); // 4

A variant you write nothing for keeps the default companion, and a variant already built cannot be
named again. The steps are already that variant's companion, so a callback with nothing to collect
returns the argument: `.implVariant("Circle", (sealer) => sealer)`. What a step returns is closed:
that variant takes no further step.
returns the argument: `.implVariant("Circle", (sealer) => sealer)`. What a step returns is closed.
That variant takes no further step.

No step chooses between a sealer and a companion. `Enum.sealer` makes every variant callable,
`Enum.companion` builds every one with `.create`, so the entry point you choose for the enum applies
Expand Down Expand Up @@ -166,13 +166,13 @@ Shape.Square.create({ id: "s2", side: 1 }); // VariantOf<Shape, "Square"> | Erro
The enum's seal checks what every variant holds; a variant's own seal checks its own payload. A
payload runs the variant's seal, then the enum's, then the default seal that brands and copies it,
so a variant with no seal of its own is still checked by the enum's. Compose them yourself where a
check depends on the other's result: inside a variant's seal, `seal(payload)` is the enum's seal, so
check depends on the other's result. Inside a variant's seal, `seal(payload)` is the enum's seal, so
its result is the enum's return, the error included.

Write `.implSeal` before the first `.implVariant`, which the type enforces. A variant's default seal
is the enum's, read from the chain as it stands.

Whatever a seal returns propagates, as it does for a Val: the union in it narrows to the variant the
Whatever a seal returns propagates, as it does for a Val. The union in it narrows to the variant the
payload named. `patch` derives through the same seal, so no derivation skips it.

On a companion, `Shape.seal` takes that tagged payload. Its return is the variants' seals as a
Expand All @@ -192,10 +192,11 @@ const Event = Enum.sealer<Event>("kind");
Event.Click({ x: 1 }); // { x: 1, kind: "Click" }
```

`Tag` goes in the third argument, beside the shared fields: the tag is a field every variant holds.
It is a marker with no key of its own, so every name is still free for a variant. The companion
takes the name again because the proxy writes it at run time, and the type argument is not readable
from a value. Forget it, misspell it, or pass one where the default applies, and the type says so.
`Tag` goes in the third argument, beside the shared fields, because the tag is a field every variant
holds. It is a marker with no key of its own, so every name is still free for a variant. The
companion takes the name again because the proxy writes it at run time, and the type argument is not
readable from a value. Forget it, misspell it, or pass one where the default applies, and the type
says so.

## Use a variant's type

Expand Down
2 changes: 1 addition & 1 deletion docs/src/immutability.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ const profile = Profile.nocopy(seed);
mutable.address.city = "Tokyo";
```

`readonly` is only a guardrail: mutable views, casts, accessors and proxies can still break this
`readonly` is only a guardrail. Mutable views, casts, accessors and proxies can still break this
contract. Default sealers require deeply readonly input; `Val.of.nocopy<V>` and custom seals leave
the responsibility to their callers and implementations. Development validates and freezes the
payload graph; production trusts the contract. `patch` still copies.
7 changes: 4 additions & 3 deletions docs/src/linting.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ pnpm add -D oxc-parser # valof does not install it for you

A rule warns where the code around the finding still works, and errors where a Val is broken: two
types the checker stops distinguishing, or a name that has to agree with another and does not.
`incomplete-disable` errors for a reason of its own: a comment naming no rule silences every one, a
`incomplete-disable` errors for a reason of its own. A comment naming no rule silences every one, a
rule added next year included.

The severity is what the [plugin](linting.md#plugin-for-eslint-and-oxlint) sets, and a project can
Expand Down Expand Up @@ -59,7 +59,7 @@ like any other.

## Plugin for ESLint and Oxlint

The same rules, one per finding kind: name one to give it its own severity, or turn it off. The
The same rules, one per finding kind. Name one to give it its own severity, or turn it off. The
project to read is one setting for all of them, a path or a list of them, and defaults to
`src/**/*.ts`. A path starting with `!` is excluded from it.

Expand Down Expand Up @@ -108,7 +108,8 @@ pnpm exec valof-lint 'src/**/*.ts' '!src/generated/**' # leave a generated tree
| `--project`, `--report-on` | the same two by name, in either order. Either can be repeated, and takes `!path` |
| `--no-<rule>` | a rule to leave out of the run, by the rule name in the finding |

A single file given as the project is refused: a duplicate brand needs the other alias to be seen.
A single file given as the project is refused, because a duplicate brand needs the other alias to be
seen.

A `!path` is excluded wherever it is written, and is removed from the run rather than only from the
report, so what a generated tree declares no longer applies to the rest.
2 changes: 1 addition & 1 deletion docs/src/patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Framework state

A value is a plain object, so a state container holds it as it stands. Replace it whole: the
A value is a plain object, so a state container holds it as it stands. Replace it whole. The
untouched subtrees keep their identity, so a dependency array sees no change.

```ts
Expand Down
53 changes: 29 additions & 24 deletions docs/src/traits.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ as a payload, so a shape no Val could ever hold is an error where it is written.
The third declares the functions. They become members of every companion that implements the trait,
and they take the value first like any other member.

`Self` stands for the implementing Val. A member may take it, and may not return it: what returns a
`Self` stands for the implementing Val. A member may take it, and may not return it. What returns a
`Self` is a constructor, and a trait has no brand to seal with.

## Implement it on a Val
Expand Down Expand Up @@ -59,13 +59,29 @@ User.greet(User({ id: "a", name: "alice" })); // "Hi, alice"

Naming the trait as the type argument asks `implTrait` for every member it declares.

Declaring the trait is what requires the payload to hold its fields: a `User` without `name` is a
Declaring the trait is what requires the payload to hold its fields. A `User` without `name` is a
type error at the declaration, not at `implTrait`.

The checker stops at the declaration. `Val<"User", …, Greetable>` typechecks with no `implTrait`
anywhere, so the `unimplemented-trait` rule in [valof-lint](linting.md) is what reports the Val that
declared a trait and never implemented it.

## A trait is a contract between Vals

A plain object that happens to hold the fields is not one of them:

```ts
// @errors: 2322
import { Trait, type Self } from "valof/experimental";
// ---cut---
type Greetable = Trait<"Greetable", { name: string }, { greet: (self: Self) => string }>;

const duck: Greetable = { name: "duck" }; // type error: the brand is missing
```

A Val declaring the trait carries its brand, and that brand is what the trait type asks for. The
fields alone do not put it there, so only a Val that declared `Greetable` is assignable to it.

## Give a default implementation

Every Val writing its own `greet` repeats the same line. A trait can implement a member itself,
Expand Down Expand Up @@ -131,7 +147,7 @@ const User = Val.sealer<User>().implTrait(Greetable);
The trait implements both, so `implTrait` needs no second argument. A Val may still pass `greet` to
replace it. Passing `shout` is an error.

Only `Final` members are exposed on the trait's own type: `Greetable.shout(user)` typechecks and
Only `Final` members are exposed on the trait's own type. `Greetable.shout(user)` typechecks and
`Greetable.greet` does not.

A default calling a `Final` member references the trait and annotates its return type:
Expand Down Expand Up @@ -177,7 +193,7 @@ const Cmd = Enum.sealer<Cmd>().implTrait(Describable, {
Cmd.describe(Cmd.Add({ id: "c1", n: 2 })); // "add 2"
```

An enum declares its shared fields and its traits in one argument: a trait brings the fields it
An enum declares its shared fields and its traits in one argument. A trait brings the fields it
requires, so declaring them again is not needed. Variants cannot implement traits.

## Hold values of different types together
Expand Down Expand Up @@ -213,37 +229,22 @@ const party: Dyn<Greetable>[] = [
];

party.map((p) => p.greet()); // ["Hi, alice", "Sir root"]
party.map((p) => p.name); // ["alice", "root"]
party.map((p) => p.name); // ["alice", "root"]: a trait field, read from the box
```

A box binds the receiver, so its members take the remaining arguments alone. The trait's fields are
readable on it, and a function taking `Greetable` accepts one.
readable on it, and a function taking `Greetable` accepts one. The Val's own fields are not. `p.id`
is a type error, because `dyn` drops the concrete type.

A box is a proxy over its value, not a Val. It has its own identity, and it has no `patch`. A
payload cannot hold one.

The two arguments belong together: the companion has to match the value's own type, so another Val's
The two arguments belong together. The companion has to match the value's own type, so another Val's
companion is rejected.

An enum boxes the same way, through its own companion:
`Describable.dyn(Cmd, Cmd.Add({ id: "c1", n: 2 }))`.

## A trait is a contract between Vals

A plain object that happens to hold the fields is not one of them:

```ts
// @errors: 2322
import { Trait, type Self } from "valof/experimental";
// ---cut---
type Greetable = Trait<"Greetable", { name: string }, { greet: (self: Self) => string }>;

const duck: Greetable = { name: "duck" }; // type error: the brand is missing
```

A Val declaring the trait carries its brand, and that brand is what the trait type asks for. The
fields alone do not put it there, so only a Val that declared `Greetable` is assignable to it.

## Several traits on one Val

Intersect them in the declaration, and implement each in its own step:
Expand All @@ -268,7 +269,7 @@ Weighed.dyn(Crate, crate).heavy(); // true

Write `&`, not `|`.

Each trait boxes on its own. No two traits on one Val may register the same member name.
Each trait boxes on its own. No two traits on one Val may declare the same member name.

## Names a member may not take

Expand All @@ -281,3 +282,7 @@ A member name is rejected when a Val could not carry it:
- `then`, which would make the companion a thenable

Each is reported where the trait is declared.

A member may not take the name of a field the implementing Val holds. That includes a field another
trait on the same Val requires. The colliding name comes from the Val, so this one is reported at
`implTrait`.
4 changes: 2 additions & 2 deletions docs/src/typescript-problems.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ const id = UserId("u1");
reference can still change a readonly view. A recursive `DeepReadonly` type protects nested fields,
but updating one immutably still means rebuilding each object on the path.

Valof makes these conventions the default: `Val` applies `DeepReadonly`, its constructor copies the
Valof makes these conventions the default. `Val` applies `DeepReadonly`, its constructor copies the
input to remove mutable aliases, `patch` rebuilds only the paths it changes, and `.nocopy` is the
explicit escape hatch for a payload that already has no mutable aliases;
[immutability](immutability.md) shows the copy and `.nocopy` contracts:
Expand Down Expand Up @@ -95,7 +95,7 @@ User.greeting(User({ name: "alice" }));

## Parse, don't validate

Valof makes parsing a convention: a companion has one `seal`, and `seal`, `create` and `patch` pass
Valof makes parsing a convention. A companion has one `seal`, and `seal`, `create` and `patch` pass
their payloads through it. A custom seal returns a validated, normalized Val or its failure, so the
result type records that parsing succeeded.

Expand Down
2 changes: 1 addition & 1 deletion docs/src/utilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,4 @@ Val.unwrap(post).tags.sort(); // ✓
```

It returns the payload as the type declares it, so a `readonly` written there survives the unwrap.
Write the payload plain: see [Allowed types](allowed-types.md).
Write the payload plain. See [Allowed types](allowed-types.md).
16 changes: 13 additions & 3 deletions skills/proofread/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,12 @@ Skip idiomatic phrasal verbs where a literal one carries the same meaning: "limi
to one", "fails with" over "comes back as", "the limit lifts" over "the cap comes off". A non-native
reader has the vocabulary for the literal verb; the idiom, they guess at.

Avoid the em dash. Splitting the sentence in two is usually the fix, and commas, "such as" or
parentheses cover the rest. Keep a colon where the second half gives the reason or the detail for
the first.
Avoid the em dash. Splitting the sentence in two is the fix, and commas, "such as" or parentheses
cover the rest.

A colon is right where a noun phrase follows it and names what came before: a list, an example, a
definition. Where a full clause follows, write two sentences instead, or "because" where the second
half is the reason for the first.

Models: Kent Beck, t_wada, Dan Abramov, mizchi. Take their plainness.

Expand Down Expand Up @@ -86,6 +89,13 @@ git diff -U0 HEAD -- '*.md' | grep -nE 'is yours|are yours|yours to|hands? back|
`boilerplate` do.
- **A noun whose referent the reader must guess.** `picks the kind` → name the alternatives. Watch
for a word that also appears as an identifier in the sample below it.
- **A colon with a clause after it.** `The Val's own fields are not: p.id is a type error` →
`The Val's own fields are not. p.id is a type error`. Where the second half is the reason, write
it: `is refused: a duplicate brand needs the other alias to be seen` →
`is refused, because a duplicate brand needs the other alias to be seen`. A noun phrase after the
colon stays, whatever its length:
`errors where a Val is broken: two types the checker stops distinguishing` names what `broken`
covers.
- **An analogy written as a rule.** Two things that union different operands are not
`the same rule`. Say what this one returns.
- **Internal vocabulary.** A word is the user's only if it reaches the export surface: an exported
Expand Down
Loading