diff --git a/docs/src/allowed-types.md b/docs/src/allowed-types.md index 85e89f9..70412db 100644 --- a/docs/src/allowed-types.md +++ b/docs/src/allowed-types.md @@ -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 diff --git a/docs/src/caveats.md b/docs/src/caveats.md index 6be1456..661b8f3 100644 --- a/docs/src/caveats.md +++ b/docs/src/caveats.md @@ -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`, 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 @@ -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 diff --git a/docs/src/custom-constructors.md b/docs/src/custom-constructors.md index a51be7c..6ed2a51 100644 --- a/docs/src/custom-constructors.md +++ b/docs/src/custom-constructors.md @@ -81,7 +81,7 @@ const User = Val.companion().implSeal((input: object, seal): Result }); ``` -The parameter takes `object` or `Record`, not `unknown`: a seal takes the payload, +The parameter takes `object` or `Record`, 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 @@ -197,7 +197,7 @@ const result = Age.seal(input); // Result 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): diff --git a/docs/src/enums.md b/docs/src/enums.md index ab969a1..6e7fc9b 100644 --- a/docs/src/enums.md +++ b/docs/src/enums.md @@ -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`: it reads the tag, selects that variant's companion, and passes the payload +`SealedPayload`. 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 @@ -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 @@ -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 @@ -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 @@ -166,13 +166,13 @@ Shape.Square.create({ id: "s2", side: 1 }); // VariantOf | 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 @@ -192,10 +192,11 @@ const Event = Enum.sealer("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 diff --git a/docs/src/immutability.md b/docs/src/immutability.md index 98f1026..38c6ea0 100644 --- a/docs/src/immutability.md +++ b/docs/src/immutability.md @@ -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` 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. diff --git a/docs/src/linting.md b/docs/src/linting.md index db2d793..a9d21e7 100644 --- a/docs/src/linting.md +++ b/docs/src/linting.md @@ -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 @@ -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. @@ -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-` | 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. diff --git a/docs/src/patterns.md b/docs/src/patterns.md index 27e9b6c..b4a8270 100644 --- a/docs/src/patterns.md +++ b/docs/src/patterns.md @@ -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 diff --git a/docs/src/traits.md b/docs/src/traits.md index 2ffc3d8..dec283c 100644 --- a/docs/src/traits.md +++ b/docs/src/traits.md @@ -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 @@ -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, @@ -131,7 +147,7 @@ const User = Val.sealer().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: @@ -177,7 +193,7 @@ const Cmd = Enum.sealer().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 @@ -213,37 +229,22 @@ const party: Dyn[] = [ ]; 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: @@ -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 @@ -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`. diff --git a/docs/src/typescript-problems.md b/docs/src/typescript-problems.md index a7277b6..16ebda0 100644 --- a/docs/src/typescript-problems.md +++ b/docs/src/typescript-problems.md @@ -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: @@ -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. diff --git a/docs/src/utilities.md b/docs/src/utilities.md index 3b9cfa9..18dd3e6 100644 --- a/docs/src/utilities.md +++ b/docs/src/utilities.md @@ -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). diff --git a/skills/proofread/SKILL.md b/skills/proofread/SKILL.md index 4502f55..a794853 100644 --- a/skills/proofread/SKILL.md +++ b/skills/proofread/SKILL.md @@ -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. @@ -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