From 62d523b0e425f86a37eef7decdae697453e608cc Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:39:54 +0900 Subject: [PATCH 01/24] feat!: remove the enum's own constructor - build a value with its variant's constructor - a tag that is a union left the other variants' fields in the value --- scripts/type-perf/fixtures/enum.ts | 5 +- src/enum.ts | 146 ++++++++++++----------------- tests/enum.test.ts | 46 ++------- 3 files changed, 72 insertions(+), 125 deletions(-) diff --git a/scripts/type-perf/fixtures/enum.ts b/scripts/type-perf/fixtures/enum.ts index 0aeac2f..202a385 100644 --- a/scripts/type-perf/fixtures/enum.ts +++ b/scripts/type-perf/fixtures/enum.ts @@ -76,10 +76,7 @@ export const derived = [ Frame.patch(frame, patch), ]; -export const sealed = [ - Event.seal({ kind: "Key", id: "e", code: "a" }), - Event.Key.create({ id: "e", code: "a" }), -]; +export const sealed = Event.Key.create({ id: "e", code: "a" }); export const boxed: Dyn[] = [Named.dyn(Event, frame.last)]; export const labels = boxed.map((b) => b.label("/")); diff --git a/src/enum.ts b/src/enum.ts index 8e3b2b4..ef91eb1 100644 --- a/src/enum.ts +++ b/src/enum.ts @@ -227,8 +227,7 @@ type Tagged> = SeedFor & }; /** - * What the boundary entry takes: a tagged payload of any one variant. The tag is what draws the - * frame, so it is part of the input rather than something the entry writes. + * What the enum's seal takes: a tagged payload of any one variant. * * @experimental */ @@ -419,16 +418,6 @@ type SealerConstructors = { [N in keyof VariantsOf]: N extends keyof VM ? VM[N] : SealerFrame>; }; -/** What a frame's constructor hands back, whichever form the frame takes. */ -type Yields = X extends { create: (...args: never[]) => infer R } - ? R - : X extends (...args: never[]) => infer R - ? R - : never; - -/** The boundary entry's return: each variant's seal, as a union. `match`'s rule. */ -type Boundary = { [N in keyof C]: Yields }[keyof C]; - /** * What `.impl` accepts. A variant's name is reserved: a member taking one would shadow the * constructor. @@ -464,27 +453,22 @@ export type EnumCompanion< * it to the first handler and reject the rest. For a guard rather than a tag, use ts-pattern. */ match: Match; - /** - * The boundary entry: selects the variant from the tag and passes the payload to its seal. The - * return is the variants' seals as a union, for the same reason `match` returns one. - */ - seal: (value: SealedPayload) => Boundary>; /** Every member `implTrait` registered. Read by `Trait`'s `dyn`. */ readonly __valof_traits: Members; }; /** - * The same for `Enum.sealer`, where the companion itself is the boundary entry and every variant - * is callable. + * The same for `Enum.sealer`, where every variant is callable. * * Inferred, not written: it is exported so your own declarations can name it. * * @experimental */ -export type EnumSealed, M = Record> = (( - value: SealedPayload, -) => Boundary>) & - SealerConstructors & +export type EnumSealed< + E extends AnyEnum, + VM = Record, + M = Record, +> = SealerConstructors & Omit> & { /** See {@link EnumCompanion.match}. */ match: Match; @@ -610,10 +594,6 @@ type Sealer = ((payload: Payload) => unknown) & { patch: (value: Payload, patch: Payload) => unknown; }; -type Companion = { - create: ((payload: Payload) => unknown) & { nocopy: (payload: Payload) => unknown }; -}; - type Builds = Record object>; /** @@ -680,11 +660,6 @@ const state = ( return made; }; - const enter = (value: Payload): unknown => { - const made = frame(value[tag] as string); - return callable ? (made as Sealer)(value) : (made as Companion).create(value); - }; - const step = ( nextBuilds: Builds, nextMembers: Record, @@ -693,64 +668,66 @@ const state = ( nextOpen: boolean, ): object => state(tag, callable, nextBuilds, nextMembers, nextTraits, nextSeal, nextOpen); - const self: object = new Proxy(callable ? () => undefined : {}, { - apply: (_, __, args: [Payload]) => enter(args[0]), - get(_, key) { - // A symbol reaches here from `await`, `JSON.stringify` and every other protocol read, and - // `then` would make the companion a thenable. Both would otherwise resolve to a frame, - // since the proxy has no list of variant names to check against. A `then` variant is an - // error at the declaration, like a `then` member anywhere else. - if (typeof key !== "string" || key === "then") return undefined; - if (Object.hasOwn(members, key)) return members[key]; - if (key === "match") { - return ( - value: Record, - handlers: Record unknown>, - ) => handlers[value[tag] as string]!(value); - } - // A sealer's boundary entry is the call, so the name stays reserved and resolves to nothing. - if (key === "seal") return callable ? undefined : enter; - if (key === "__valof_traits") return traits; - if (key === "impl") { - // One call, which closes every step: what it returns carries no `impl` of its own. - return open - ? (fns: Payload = {}) => step(builds, { ...members, ...fns }, traits, seal, false) - : undefined; - } - if (key === "implVariant") { - return open - ? (name: string, build: (sealer: object) => object) => - step({ ...builds, [name]: build }, members, traits, seal, true) - : undefined; - } - if (key === "implSeal") { - return open && !callable - ? (custom: Seal) => step(builds, members, traits, custom, true) - : undefined; - } - if (key === "implTrait") { - // The trait's own members and the enum's, merged the way `val.ts` merges them. - return open - ? ( - trait: { __valof_shared?: Record }, - fns: Record = {}, - ) => { - const grown = { ...traits, ...(trait.__valof_shared ?? trait), ...fns }; - return step(builds, { ...members, ...grown }, grown, seal, true); - } - : undefined; - } - return frame(key); + const self: object = new Proxy( + {}, + { + get(_, key) { + // A symbol reaches here from `await`, `JSON.stringify` and every other protocol read, and + // `then` would make the companion a thenable. Both would otherwise resolve to a frame, + // since the proxy has no list of variant names to check against. A `then` variant is an + // error at the declaration, like a `then` member anywhere else. + if (typeof key !== "string" || key === "then") return undefined; + if (Object.hasOwn(members, key)) return members[key]; + if (key === "match") { + return ( + value: Record, + handlers: Record unknown>, + ) => handlers[value[tag] as string]!(value); + } + // Reserved, so no variant takes it. + if (key === "seal") return undefined; + if (key === "__valof_traits") return traits; + if (key === "impl") { + // One call, which closes every step: what it returns carries no `impl` of its own. + return open + ? (fns: Payload = {}) => step(builds, { ...members, ...fns }, traits, seal, false) + : undefined; + } + if (key === "implVariant") { + return open + ? (name: string, build: (sealer: object) => object) => + step({ ...builds, [name]: build }, members, traits, seal, true) + : undefined; + } + if (key === "implSeal") { + return open && !callable + ? (custom: Seal) => step(builds, members, traits, custom, true) + : undefined; + } + if (key === "implTrait") { + // The trait's own members and the enum's, merged the way `val.ts` merges them. + return open + ? ( + trait: { __valof_shared?: Record }, + fns: Record = {}, + ) => { + const grown = { ...traits, ...(trait.__valof_shared ?? trait), ...fns }; + return step(builds, { ...members, ...grown }, grown, seal, true); + } + : undefined; + } + return frame(key); + }, }, - }); + ); return self; }; /** @experimental */ export const Enum = { /** - * Builds the constructors for an enum: every variant is callable, and the companion itself is - * the boundary entry. `Enum.companion` is the same shape for an enum with a smart constructor. + * Builds the constructors for an enum: every variant is callable. `Enum.companion` is the same + * shape for an enum with a smart constructor. * * The tag name is passed again here because the proxy has to write it at run time, and the type * argument is not readable from a value. @@ -758,8 +735,7 @@ export const Enum = { sealer: (...tag: TagArg): EnumSealer => state(tag[0] ?? "_tag", true, {}, {}, {}, undefined, true) as EnumSealer, /** - * Bundles an enum's members with a seal of its own. Every variant builds with `.create`, and - * the boundary entry is `.seal`. + * Bundles an enum's members with a seal of its own. Every variant builds with `.create`. */ companion: (...tag: TagArg): EnumBuilder => state(tag[0] ?? "_tag", false, {}, {}, {}, undefined, true) as EnumBuilder, diff --git a/tests/enum.test.ts b/tests/enum.test.ts index 636164a..fc65d82 100644 --- a/tests/enum.test.ts +++ b/tests/enum.test.ts @@ -543,37 +543,7 @@ describe("a seal of its own", () => { expect(Plain.Circle.create({ r: 0 })).toBeInstanceOf(RangeError); expectTypeOf(Plain.Square.create({ side: 3 })).toEqualTypeOf>(); expect(Plain.Square.create({ side: 3 })).toEqual(square); - }); - - test("a companion with no seal at all still builds and still takes the wire", () => { - const Plain = Enum.companion().impl(); - expect(Plain.Circle.create({ r: 2 })).toEqual(circle); - expect(Plain.seal({ side: 3, _tag: "Square" })).toEqual(square); - expectTypeOf(Plain.Circle.create({ r: 2 })).toEqualTypeOf>(); - expectTypeOf(Plain.Circle).not.toHaveProperty("seal"); - }); - - describe("the boundary entry", () => { - test("draws the frame from the tag and hands over to that variant's seal", () => { - const sealed = Money.seal({ id: "m1", yen: 1, _tag: "Cash" }); - expectTypeOf(sealed).toEqualTypeOf< - VariantOf | VariantOf | RangeError - >(); - expect(sealed).toEqual({ id: "m1", yen: 1, _tag: "Cash" }); - expect(Money.seal({ id: "m2", limit: 2, _tag: "Card" })).toEqual({ - id: "m2", - limit: 2, - _tag: "Card", - }); - expect(Money.seal({ id: "m1", yen: 0, _tag: "Cash" })).toBeInstanceOf(RangeError); - }); - - test("a sealer is the entry itself, and hands back the union", () => { - const shape = Shape({ r: 2, _tag: "Circle" }); - expectTypeOf(shape).toEqualTypeOf(); - expect(shape).toEqual(circle); - expect(Shape({ side: 3, _tag: "Square" })).toEqual(square); - }); + expectTypeOf(Plain.Square).not.toHaveProperty("seal"); }); test("a sealer has no seal to replace: its constructors are the default one", () => { @@ -664,12 +634,16 @@ describe("the chain closes", () => { }); describe("the companion is a proxy", () => { - test("a sealer's boundary entry is the call, so `seal` resolves to nothing", () => { + // As in Rust, a value is built by its variant's constructor. A payload whose tag is a union + // would carry the other variants' fields into the value. + test("the enum has no constructor of its own", () => { + const Plain = Enum.companion().impl(); + expectTypeOf(Plain).not.toHaveProperty("seal"); + expect((Plain as unknown as Record)["seal"]).toBeUndefined(); + expectTypeOf(Shape).not.toHaveProperty("seal"); expect((Shape as unknown as Record)["seal"]).toBeUndefined(); - expect(Enum.companion().impl().seal({ r: 1, _tag: "Circle" })).toEqual({ - r: 1, - _tag: "Circle", - }); + // @ts-expect-error a sealer is not callable + expect(() => Shape({ r: 1, _tag: "Circle" })).toThrow(TypeError); }); test("so a symbol and `then` come back undefined", async () => { From d08e1b257cccc6c155b1b96aa3675e41778040cc Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:54:47 +0900 Subject: [PATCH 02/24] docs(notes): record removing the enum's own constructor --- notes/design.md | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/notes/design.md b/notes/design.md index 8a9b826..fab62e4 100644 --- a/notes/design.md +++ b/notes/design.md @@ -36,7 +36,7 @@ import { Val } from "valof"; - **§14 valof-lint** companion のメンバが静的解析から見えない問題。パーサ選定、同梱の判断、却下した ts-morph(§14.5)、カスタム equals を持つ子の規則(§14.7、撤回)、ルールの表現と構成(§14.8)、エディタ統合(§14.9、overlay まで実装)、テストの穴(§14.10)、テストの置き場所(§14.11)、型名と一致しないブランド(§14.12)、Val / Trait の 2 つ目の名前(§14.25)、却下した自己参照でない `Rec` の規則(§14.26)、`unused-member` と型が拒む名前(§14.27、型で結んだ lint の名前)、型名と一致しない companion(§14.21)、型と別ファイルの companion(§14.22)、companion を持つ型の `Val.of`(§14.23)、型引数を書かない `Val.of`(§14.24)、`Val` の綴り(§14.13)、欠けている disable コメント(§14.14)、効いていない disable コメント(§14.15)、ファイル全体の disable(§14.16)、`--no-` を受けない規則(§14.17)、指示についての規則の見せ方(§14.18)、oxlint の版と設定の正本(§14.19)、LSP でのホスト統合テスト(§14.20、撤回)、Trait の宣言・実装・`dyn` の構文追跡(§15.1) - **§15 v2 候補** - **15.1 `Trait`** `Final` マーカーと 1 段の `impl`、交差する trait ブランドと型引数だけで落とす `|`(却下したタプル)、`Self` マーカーと戻り値禁止、`dyn`(`Box` 相当)、却下した WeakMap ディスパッチ、需要と `dyn` を落とせる形の却下、experimental subpath(却下した機能ごとの subpath)、`impl` のコールバック形(引数は実装済みの final だけ、却下した実装側 companion) - - **15.2 `Enum`** §7.4 の見直し。Variant をレコードに宣言して union を導出、ブランドの導出、タグ名のカスタムと `tag-mismatch`、companion に置く `match`、ts-pattern との線引き、型を確かめた記録(共通フィールド、`match` の型引数、`VariantOf` の表示、却下した戻り値の型引数・自由関数の `match`・Val のレコード、Trait の実装、タグ名を `Tag<…>` で渡すこと、トップレベルの条件型が宣言出力を壊すこと)、実装して分かったこと(Fault の置き場所、Variant 1 個の禁止、`then` を 3 箇所で落とす、宣言出力の CI、variance 測定と型コスト)、Variant ごとの seal と union の `seal`(入口を 2 つに分ける、builder を callback で渡す、`impl` で鎖を閉じる、却下した値の形)、`implVariant` を Variant ごとの鎖にしたこと、steps を枠そのものにしたこと、タグ名の渡し方を変える 4 案の却下、companion を渡さない形(val.ts の門を共有する、`PayloadKeys` の分配、入口 2 つぶんのテスト) + - **15.2 `Enum`** §7.4 の見直し。Variant をレコードに宣言して union を導出、ブランドの導出、タグ名のカスタムと `tag-mismatch`、companion に置く `match`、ts-pattern との線引き、型を確かめた記録(共通フィールド、`match` の型引数、`VariantOf` の表示、却下した戻り値の型引数・自由関数の `match`・Val のレコード、Trait の実装、タグ名を `Tag<…>` で渡すこと、トップレベルの条件型が宣言出力を壊すこと)、実装して分かったこと(Fault の置き場所、Variant 1 個の禁止、`then` を 3 箇所で落とす、宣言出力の CI、variance 測定と型コスト)、Variant ごとの seal と union の `seal`(入口を 2 つに分ける、builder を callback で渡す、`impl` で鎖を閉じる、却下した値の形)、Enum 自身のコンストラクタの撤回、`implVariant` を Variant ごとの鎖にしたこと、steps を枠そのものにしたこと、タグ名の渡し方を変える 4 案の却下、companion を渡さない形(val.ts の門を共有する、`PayloadKeys` の分配、入口 2 つぶんのテスト) - **15.3 `path`** seal をまたぐ patch の合成。`abort` を合成側に置く判断、ハンドラが最終段である理由(HKT)、`glue` の `open` / `close`、`each` / `where`、却下した `deepPatch` - **15.4 `.impl` のコールバック形** 自分の companion を参照すると推論が回らない(TS7022)。contextual typing がコールバック越しでも効くことの実測、`implTrait` も callback、予算、却下したメンバのカリー化と、下流の `.impl` を型で塞ぐ案。**鎖は 2026-09-18 に閉じた**(1 回だけ、兄弟は注釈で呼ぶ、Trait の実装の受け手を `Tr` に、却下した型レベルの案内と lint 規則) - **§16 予算の責務** バンドルと型を別のスクリプトに割る。宣言のバイト数を type-perf へ、予算を 64 kB に上げた理由 @@ -5619,6 +5619,8 @@ Variant の数では増えない。予算は 45,000 に置いた。 #### Variant ごとの seal と union の `seal`、2026-09-16 実装 +**2026-10-14: union の `seal` は撤回した。**「Enum 自身のコンストラクタを撤回」を見る。 + **動機は境界の分岐。**タグ付きのペイロードが来る場所で、利用者は今どの Variant かを自分で見て、対応する コンストラクタを呼んでいる。`Shape.seal(payload)` があれば、タグで枠を引くのはライブラリの仕事になる。 @@ -5708,6 +5710,33 @@ Variant の枠が Val の companion なので追加の実装が要らない。 - **予算。**type-perf は 45,000 から 90,000 へ。Variant 1 個あたりは約 1,100 で線形、残りは variance 測定の定数。bundle-size は `Val` + `Enum` で 1.10 kB から 1.28 kB gzip、予算 1.63 kB は動かさない +#### Enum 自身のコンストラクタを撤回、2026-10-14 + +**`Shape(payload)` と `Shape.seal(payload)` を消した。**値は Variant のコンストラクタだけが作る。Rust の +enum も Variant ごとに作る(`Command::New { … }`)。 + +**タグが union だと、他の Variant のフィールドが値に残る。**引数の型 `SealedPayload` は Variant ごとの +タグ付き payload の union である。TypeScript は、判別子が union のオブジェクトでも、判別子の各値について +代入できれば受け入れる。全 Variant のフィールドを持つオブジェクトがこれで通る。 + +```ts +Command.seal({ _tag: TAGS[name] ?? "Invalid", count: Number(argument), message: "…" }); +// new 2 → {"_tag":"New","count":2,"message":"…"} +``` + +型の上では `New` に `message` は無いが、値には入っていて、`equals` と JSON に出る。proxy は Variant の +フィールドを知らないので、実行時に落とせない。 + +**却下: 引数を Variant ごとに排他的な union にする。**他の Variant にしかないキーを `never` にすれば、 +上の呼び出しは型エラーになる。それでもタグを実行時に決める呼び出し側は、タグで分岐して Variant ごとの +payload を作ることになる。分岐を書くなら、その先で Variant のコンストラクタを呼べば足りる。 + +- `seal` は Variant 名として予約したまま。proxy も `undefined` を返す。緩めるのは後方互換 +- Enum の `implSeal`(共有フィールドの検査)と Variant の `.seal` は残る。`SealedPayload` は Enum の seal の + 引数として残る +- enum の instantiation は 32,653 → 32,357、`index.d.mts` は 59.48 → 58.56 kB、Enum を含む production + gzip は 1.27 → 1.24 kB + #### `implVariant` は Variant ごとの鎖、2026-09-18 実装 **レコード 1 個から鎖に変えた。**ネストが 1 段減り、外側の callback が消える。 From 79f30366be33722e70d8a8a4e4d5b545086a8b27 Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:01:39 +0900 Subject: [PATCH 03/24] feat: add `[Tag].match` to dispatch an enum on a tag alone --- scripts/type-perf/fixtures/enum.ts | 12 ++++++---- src/enum.ts | 34 ++++++++++++++++++++++++++++ src/experimental.ts | 3 +-- tests/enum.test.ts | 36 +++++++++++++++++++++++++++++- 4 files changed, 78 insertions(+), 7 deletions(-) diff --git a/scripts/type-perf/fixtures/enum.ts b/scripts/type-perf/fixtures/enum.ts index 202a385..7c588cb 100644 --- a/scripts/type-perf/fixtures/enum.ts +++ b/scripts/type-perf/fixtures/enum.ts @@ -1,8 +1,9 @@ // What `Enum` costs: the union derived from the declaration, `match`'s narrowing and its -// exhaustiveness, a variant's own members, a seal on the enum and one on a variant, a trait -// implemented over the union, and a variant nested in a Val, where the patch boundary runs. +// exhaustiveness, `match` on a tag alone, a variant's own members, a seal on the enum and one on a +// variant, a trait implemented over the union, and a variant nested in a Val, where the patch +// boundary runs. import { Val, type Patch, type SeedOf } from "valof"; -import { Enum, Trait, type Dyn, type Self, type Tag, type VariantOf } from "valof/experimental"; +import { Enum, Tag, Trait, type Dyn, type Self, type VariantOf } from "valof/experimental"; type Shape = Enum< "Shape", @@ -76,7 +77,10 @@ export const derived = [ Frame.patch(frame, patch), ]; -export const sealed = Event.Key.create({ id: "e", code: "a" }); +export const sealed = Event[Tag].match(Math.random() > 0.5 ? "Key" : "Click", { + Key: () => Event.Key.create({ id: "e", code: "a" }), + Click: () => Event.Click.create({ id: "e", x: 0, y: 0 }), +}); export const boxed: Dyn[] = [Named.dyn(Event, frame.last)]; export const labels = boxed.map((b) => b.label("/")); diff --git a/src/enum.ts b/src/enum.ts index ef91eb1..f56377f 100644 --- a/src/enum.ts +++ b/src/enum.ts @@ -14,6 +14,13 @@ import { type Wired, } from "./val.ts"; +/** + * The key of what an enum dispatches on a tag alone: `Command[Tag].match(tag, handlers)`. + * + * @experimental + */ +export const Tag = "__valof_tag"; + /** Type-only carrier for the tag name. Private and protected members stay out of `keyof`. */ declare class TagPhantom { private readonly __valof_internal_phantom_tag: T; @@ -431,6 +438,26 @@ type Match = >( handlers: Only, ) => ReturnType; +/** What an enum dispatches on a tag alone, under {@link Tag}. */ +type ByTag = { + /** + * Dispatches on a tag before there is a value, as when a tag arrives apart from its payload. Every + * tag the argument can hold needs a handler. A handler takes nothing and calls its variant's + * constructor, which checks the payload literal for fields the variant does not hold. + * + * ```ts + * Command[Tag].match(tag, { + * New: () => Command.New({ count }), + * Check: () => Command.Check({}), + * }); + * ``` + */ + readonly match: , H extends { [N in T]: () => unknown }>( + tag: T, + handlers: H & { [N in keyof H]: N extends T ? unknown : "the tag never takes this value" }, + ) => ReturnType; +}; + /** * An enum's members: one frame per variant, `match`, `seal`, and whatever the steps registered. * @@ -453,6 +480,7 @@ export type EnumCompanion< * it to the first handler and reject the rest. For a guard rather than a tag, use ts-pattern. */ match: Match; + readonly [Tag]: ByTag; /** Every member `implTrait` registered. Read by `Trait`'s `dyn`. */ readonly __valof_traits: Members; }; @@ -472,6 +500,7 @@ export type EnumSealed< Omit> & { /** See {@link EnumCompanion.match}. */ match: Match; + readonly [Tag]: ByTag; /** Every member `implTrait` registered. Read by `Trait`'s `dyn`. */ readonly __valof_traits: Members; }; @@ -596,6 +625,10 @@ type Sealer = ((payload: Payload) => unknown) & { type Builds = Record object>; +const byTag = { + match: (tag: string, handlers: Record unknown>) => handlers[tag]!(), +}; + /** * One builder state. The companion is a proxy: it does not know the variant names, so anything it * does not handle itself becomes a frame. @@ -686,6 +719,7 @@ const state = ( } // Reserved, so no variant takes it. if (key === "seal") return undefined; + if (key === Tag) return byTag; if (key === "__valof_traits") return traits; if (key === "impl") { // One call, which closes every step: what it returns carries no `impl` of its own. diff --git a/src/experimental.ts b/src/experimental.ts index 2664335..19d85fe 100644 --- a/src/experimental.ts +++ b/src/experimental.ts @@ -4,7 +4,7 @@ * The design is still changing. */ -export { Enum } from "./enum.ts"; +export { Enum, Tag } from "./enum.ts"; export type { AnyEnum, @@ -16,7 +16,6 @@ export type { SealedPayload, SeedFor, SharedOf, - Tag, TagOf, VariantOf, VariantsOf, diff --git a/tests/enum.test.ts b/tests/enum.test.ts index fc65d82..2547b5b 100644 --- a/tests/enum.test.ts +++ b/tests/enum.test.ts @@ -7,7 +7,7 @@ import { type Final, type SeedFor, type Self, - type Tag, + Tag, type VariantOf, } from "../src/experimental.ts"; @@ -91,6 +91,40 @@ describe("match", () => { }); }); +describe("match on a tag alone", () => { + const pick = (name: string): "Circle" | "Square" => (name === "round" ? "Circle" : "Square"); + + test("runs the handler the tag selects, and returns the handlers' union", () => { + const build = (name: string) => + Shape[Tag].match(pick(name), { + Circle: () => Shape.Circle({ r: 2 }), + Square: () => `square`, + }); + expectTypeOf(build).returns.toEqualTypeOf | string>(); + expect([build("round"), build("box")]).toEqual([circle, "square"]); + expect( + Enum.companion() + .impl() + [Tag].match("Circle", { Circle: () => 0 }), + ).toBe(0); + }); + + test("needs a handler for every tag the argument can hold, and no other", () => { + // @ts-expect-error Property 'Square' is missing + Shape[Tag].match(pick("round"), { Circle: () => 0 }); + Shape[Tag].match("Circle", { + Circle: () => 0, + // @ts-expect-error the tag never takes this value + Square: () => 0, + }); + }); + + test("a tag the enum does not declare is rejected", () => { + // @ts-expect-error not assignable to '"Circle" | "Square"' + Shape[Tag].match("Triangle", { Triangle: () => 0 }); + }); +}); + describe("the tag", () => { test("narrows the union", () => { const read = (shape: Shape): number => { From 846525b45d61a3ebb6d54719f55e89d7aa763753 Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:07:30 +0900 Subject: [PATCH 04/24] feat: add `[Tag].key` to read an enum's tag field name --- src/enum.ts | 10 +++++++--- tests/enum.test.ts | 6 ++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/enum.ts b/src/enum.ts index f56377f..f6ece42 100644 --- a/src/enum.ts +++ b/src/enum.ts @@ -456,6 +456,11 @@ type ByTag = { tag: T, handlers: H & { [N in keyof H]: N extends T ? unknown : "the tag never takes this value" }, ) => ReturnType; + /** + * The name of the tag field, for a schema library that asks for the discriminator: + * `z.discriminatedUnion(Command[Tag].key, [...])`. + */ + readonly key: TagOf; }; /** @@ -625,9 +630,7 @@ type Sealer = ((payload: Payload) => unknown) & { type Builds = Record object>; -const byTag = { - match: (tag: string, handlers: Record unknown>) => handlers[tag]!(), -}; +const matchTag = (tag: string, handlers: Record unknown>) => handlers[tag]!(); /** * One builder state. The companion is a proxy: it does not know the variant names, so anything it @@ -645,6 +648,7 @@ const state = ( // Remembered for the allocation, not for identity: a frame built twice behaves the same, and // rebuilding one on every read would make a fresh closure per member per access. const frames = new Map(); + const byTag = { match: matchTag, key: tag }; const built = (name: string, custom: Seal | undefined, fns: Payload): object => { const mint = (payload: Payload) => ({ ...payload, [tag]: name }); diff --git a/tests/enum.test.ts b/tests/enum.test.ts index 2547b5b..b64567c 100644 --- a/tests/enum.test.ts +++ b/tests/enum.test.ts @@ -157,6 +157,12 @@ describe("the tag", () => { expect(Named.Click.create({ x: 1 })).toEqual({ x: 1, kind: "Click" }); }); + test("`[Tag].key` returns it, typed as the literal", () => { + expectTypeOf(Event[Tag].key).toEqualTypeOf<"kind">(); + expect(Event[Tag].key).toBe("kind"); + expect(Shape[Tag].key).toBe("_tag"); + }); + test("reserves no variant name of its own", () => { type Git = Enum<"Git", { commit: { sha: string }; tag: { name: string } }, Tag<"type">>; const Git = Enum.sealer("type"); From 6872f53b8740349e38da40c83bdf977b7254487b Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:23:43 +0900 Subject: [PATCH 05/24] feat: restore the enum's `seal`, rejecting another variant's fields --- scripts/type-perf/fixtures/enum.ts | 3 +- src/enum.ts | 50 ++++++++++++++++++++++++++++-- tests/enum.test.ts | 50 +++++++++++++++++++++++++----- 3 files changed, 92 insertions(+), 11 deletions(-) diff --git a/scripts/type-perf/fixtures/enum.ts b/scripts/type-perf/fixtures/enum.ts index 7c588cb..f621a80 100644 --- a/scripts/type-perf/fixtures/enum.ts +++ b/scripts/type-perf/fixtures/enum.ts @@ -77,7 +77,8 @@ export const derived = [ Frame.patch(frame, patch), ]; -export const sealed = Event[Tag].match(Math.random() > 0.5 ? "Key" : "Click", { +export const sealed = Event.seal({ kind: "Key", id: "e", code: "a" }); +export const byTag = Event[Tag].match(Math.random() > 0.5 ? "Key" : "Click", { Key: () => Event.Key.create({ id: "e", code: "a" }), Click: () => Event.Click.create({ id: "e", x: 0, y: 0 }), }); diff --git a/src/enum.ts b/src/enum.ts index f6ece42..756e4fe 100644 --- a/src/enum.ts +++ b/src/enum.ts @@ -438,6 +438,29 @@ type Match = >( handlers: Only, ) => ReturnType; +/** Every field some variant declares. */ +type VariantFields = { + [N in keyof VariantsOf]: keyof VariantsOf[N]; +}[keyof VariantsOf]; + +/** + * A tagged payload that holds no other variant's field. TypeScript accepts an object whose tag is a + * union wherever each of the tag's values fits, so one holding every variant's fields would pass + * {@link SealedPayload} and keep the fields the selected variant does not declare. + */ +type Exclusive = { + [N in keyof VariantsOf]: Tagged & { + readonly [K in Exclude, keyof VariantsOf[N]>]?: never; + }; +}[keyof VariantsOf]; + +/** What a frame's constructor returns, whichever form the frame takes. */ +type Yields = X extends { create: (...args: never[]) => infer R } + ? R + : X extends (...args: never[]) => infer R + ? R + : never; + /** What an enum dispatches on a tag alone, under {@link Tag}. */ type ByTag = { /** @@ -463,6 +486,11 @@ type ByTag = { readonly key: TagOf; }; +/** See {@link EnumCompanion.seal}. */ +type EnumSeal = (payload: Exclusive) => { + [N in keyof C]: Yields; +}[keyof C]; + /** * An enum's members: one frame per variant, `match`, `seal`, and whatever the steps registered. * @@ -485,6 +513,14 @@ export type EnumCompanion< * it to the first handler and reject the rest. For a guard rather than a tag, use ts-pattern. */ match: Match; + /** + * Builds the variant a tagged payload names, through that variant's constructor. For a payload + * a schema library has already split by the tag. The return is the variants' constructors' + * returns, as a union. + * + * A payload holding a field of a variant other than the one its tag names is a type error. + */ + seal: EnumSeal>; readonly [Tag]: ByTag; /** Every member `implTrait` registered. Read by `Trait`'s `dyn`. */ readonly __valof_traits: Members; @@ -505,6 +541,8 @@ export type EnumSealed< Omit> & { /** See {@link EnumCompanion.match}. */ match: Match; + /** See {@link EnumCompanion.seal}. */ + seal: EnumSeal>; readonly [Tag]: ByTag; /** Every member `implTrait` registered. Read by `Trait`'s `dyn`. */ readonly __valof_traits: Members; @@ -628,6 +666,10 @@ type Sealer = ((payload: Payload) => unknown) & { patch: (value: Payload, patch: Payload) => unknown; }; +type Companion = { + create: (payload: Payload) => unknown; +}; + type Builds = Record object>; const matchTag = (tag: string, handlers: Record unknown>) => handlers[tag]!(); @@ -697,6 +739,11 @@ const state = ( return made; }; + const enter = (value: Payload): unknown => { + const made = frame(value[tag] as string); + return callable ? (made as Sealer)(value) : (made as Companion).create(value); + }; + const step = ( nextBuilds: Builds, nextMembers: Record, @@ -721,8 +768,7 @@ const state = ( handlers: Record unknown>, ) => handlers[value[tag] as string]!(value); } - // Reserved, so no variant takes it. - if (key === "seal") return undefined; + if (key === "seal") return enter; if (key === Tag) return byTag; if (key === "__valof_traits") return traits; if (key === "impl") { diff --git a/tests/enum.test.ts b/tests/enum.test.ts index b64567c..e3a8f38 100644 --- a/tests/enum.test.ts +++ b/tests/enum.test.ts @@ -6,6 +6,7 @@ import { type Dyn, type Final, type SeedFor, + type SealedPayload, type Self, Tag, type VariantOf, @@ -125,6 +126,37 @@ describe("match on a tag alone", () => { }); }); +describe("seal a payload by its tag", () => { + type Command = Enum<"Command", { New: { count: number }; Fail: { message: string } }>; + const Command = Enum.sealer(); + type Wire = SealedPayload; + + test("builds the variant the tag names, and returns the variants' union", () => { + const parse = (wire: Wire) => Command.seal(wire); + expectTypeOf(parse).returns.toEqualTypeOf(); + expect(parse({ _tag: "New", count: 2 })).toEqual(Command.New({ count: 2 })); + expect(parse({ _tag: "Fail", message: "m" })).toEqual(Command.Fail({ message: "m" })); + }); + + // TypeScript accepts a union tag wherever each of its values fits, so the payload below would + // otherwise build a `New` that holds `message`. + test("a payload holding another variant's field is rejected, fresh or not", () => { + const tag: "New" | "Fail" = Math.random() > 0.5 ? "New" : "Fail"; + // @ts-expect-error Types of property '_tag' are incompatible + Command.seal({ _tag: tag, count: 2, message: "m" }); + // No excess property check reaches an object that is not fresh, even with one tag. + const held = { _tag: "New" as const, count: 2, message: "m" }; + // @ts-expect-error Types of property 'message' are incompatible + Command.seal(held); + }); + + test("a field two variants share is not another variant's", () => { + type Mark = Enum<"Mark", { Pin: { at: number }; Flag: { at: number }; Note: { text: string } }>; + const Mark = Enum.sealer(); + expect(Mark.seal({ _tag: "Pin", at: 1 })).toEqual(Mark.Pin({ at: 1 })); + }); +}); + describe("the tag", () => { test("narrows the union", () => { const read = (shape: Shape): number => { @@ -530,6 +562,15 @@ describe("a seal of its own", () => { expect(Money.Cash.create({ id: "m1", yen: 0 })).toBeInstanceOf(RangeError); }); + test("`seal` runs the seal of the variant the tag names", () => { + const sealed = Money.seal({ id: "m1", yen: 1, _tag: "Cash" }); + expectTypeOf(sealed).toEqualTypeOf< + VariantOf | VariantOf | RangeError + >(); + expect(sealed).toEqual({ id: "m1", yen: 1, _tag: "Cash" }); + expect(Money.seal({ id: "m1", yen: 0, _tag: "Cash" })).toBeInstanceOf(RangeError); + }); + test("the variant runs first, then the enum, then the default seal", () => { ran.length = 0; Money.Cash.create({ id: "m1", yen: 1 }); @@ -674,14 +715,7 @@ describe("the chain closes", () => { }); describe("the companion is a proxy", () => { - // As in Rust, a value is built by its variant's constructor. A payload whose tag is a union - // would carry the other variants' fields into the value. - test("the enum has no constructor of its own", () => { - const Plain = Enum.companion().impl(); - expectTypeOf(Plain).not.toHaveProperty("seal"); - expect((Plain as unknown as Record)["seal"]).toBeUndefined(); - expectTypeOf(Shape).not.toHaveProperty("seal"); - expect((Shape as unknown as Record)["seal"]).toBeUndefined(); + test("a sealer is not callable: a tagged payload goes to `seal`", () => { // @ts-expect-error a sealer is not callable expect(() => Shape({ r: 1, _tag: "Circle" })).toThrow(TypeError); }); From 542f623a854f1dc34b68a217a6a0574d9131e75b Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:25:48 +0900 Subject: [PATCH 06/24] docs: describe the enum's `seal`, `[Tag].match` and `[Tag].key` --- docs/src/api.md | 6 ++++-- docs/src/enums.md | 38 ++++++++++++++++++++++++++++++++------ 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/docs/src/api.md b/docs/src/api.md index 6eb1961..af782fe 100644 --- a/docs/src/api.md +++ b/docs/src/api.md @@ -65,10 +65,12 @@ re-export a companion. | `Enum.sealer(tag?)` | starts an enum whose variants are callable | | `Enum.companion(tag?)` | the same, for an enum with a seal of its own | | `E.match(value, handlers)` | dispatches on the tag, exhaustively | +| `E[Tag].match(tag, handlers)` | the same on a tag alone, before there is a value | +| `E.seal(payload)` | builds the variant the payload's tag names | +| `E[Tag].key` | the tag field's name | | `E[Variant](payload)` | builds that variant, writing the tag | | `E[Variant].create(payload)` | the same on a companion, through its seal | | `E[Variant].patch(value, patch)` | derives a variant, never reaching the tag | -| `E(payload)` / `E.seal(payload)` | selects the variant from the tag, and seals | | `.impl(fns?)` | adds members taking the union, and ends the chain | | `.implVariant(N, sealer => …)` | builds one variant from its own steps | | `.implSeal(seal)` | replaces the seal every variant passes | @@ -77,7 +79,7 @@ re-export a companion. | `Tag` | names the tag field, intersected into `X` | | `VariantOf` | the type of one variant | | `SeedFor` | what that variant's constructor takes | -| `SealedPayload` | what `E(payload)` takes, tag included | +| `SealedPayload` | what the enum's seal takes, tag included | | `VariantsOf` / `SharedOf` | the declared variants, and the shared fields | | `NameOf` / `TagOf` | the enum's name, and the tag field's name | | `AnyEnum` | a constraint over any enum | diff --git a/docs/src/enums.md b/docs/src/enums.md index 6e7fc9b..a09e66a 100644 --- a/docs/src/enums.md +++ b/docs/src/enums.md @@ -35,9 +35,10 @@ Call a variant through the companion. `const { Circle } = Shape` works, and a re 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 -to its seal. +constructor. `Shape.seal` takes a payload that already carries the tag, such as one a schema library +parsed. It reads the tag and passes the payload to that variant's constructor. A field only another +variant declares is a type error, even where the tag is a union. When the tag arrives alone, see +[Build from a tag](#build-from-a-tag). A variant is a Val. It patches, it compares, and it nests. A patch cannot reach the tag, so it cannot switch variants. @@ -80,6 +81,28 @@ 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. +## Build from a tag + +When the tag arrives without its payload, `[Tag].match` selects the handler from the tag alone. Here +a form picks the shape in one field and its size in another: + +```ts +import { Enum, Tag } from "valof/experimental"; + +type Shape = Enum<"Shape", { Circle: { r: number }; Square: { side: number } }>; +const Shape = Enum.sealer(); +declare const form: { kind: "Circle" | "Square"; size: number }; +// ---cut--- +const shape = Shape[Tag].match(form.kind, { + Circle: () => Shape.Circle({ r: form.size / 2 }), + Square: () => Shape.Square({ side: form.size }), +}); +``` + +Every tag the argument's type allows needs a handler, so adding a variant to the declaration fails +here too, as it does for `match`. A handler takes no argument and calls its variant's constructor, +so each payload is checked against its own variant. + ## Common shape for every variant The third argument is the shape: the fields every variant holds. It is the same argument a @@ -175,8 +198,8 @@ 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 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 -union. +`Shape.seal` passes a tagged payload through the same seals. Its return is the variants' returns, as +a union. ## Custom tag key @@ -184,12 +207,13 @@ The tag is real data, not a phantom. It crosses the wire, so you can customize t already has one: ```ts -import { Enum, type Tag } from "valof/experimental"; +import { Enum, Tag } from "valof/experimental"; // ---cut--- type Event = Enum<"Event", { Click: { x: number }; Key: { code: string } }, Tag<"kind">>; const Event = Enum.sealer("kind"); Event.Click({ x: 1 }); // { x: 1, kind: "Click" } +Event[Tag].key; // "kind" ``` `Tag` goes in the third argument, beside the shared fields, because the tag is a field every variant @@ -198,6 +222,8 @@ companion takes the name again because the proxy writes it at run time, and the readable from a value. Forget it, misspell it, or pass one where the default applies, and the type says so. +`Event[Tag].key` returns the name, for a schema library that asks for the discriminator. + ## Use a variant's type `VariantOf` is the type of a single variant, and it is what errors and hovers print. Write it where From e3a8cb4d7afc1426cd072e30d5ebd3788fa605de Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:25:49 +0900 Subject: [PATCH 07/24] docs(notes): record the exclusive `seal` and `[Tag]` --- notes/design.md | 118 +++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 101 insertions(+), 17 deletions(-) diff --git a/notes/design.md b/notes/design.md index fab62e4..3071d1c 100644 --- a/notes/design.md +++ b/notes/design.md @@ -36,7 +36,7 @@ import { Val } from "valof"; - **§14 valof-lint** companion のメンバが静的解析から見えない問題。パーサ選定、同梱の判断、却下した ts-morph(§14.5)、カスタム equals を持つ子の規則(§14.7、撤回)、ルールの表現と構成(§14.8)、エディタ統合(§14.9、overlay まで実装)、テストの穴(§14.10)、テストの置き場所(§14.11)、型名と一致しないブランド(§14.12)、Val / Trait の 2 つ目の名前(§14.25)、却下した自己参照でない `Rec` の規則(§14.26)、`unused-member` と型が拒む名前(§14.27、型で結んだ lint の名前)、型名と一致しない companion(§14.21)、型と別ファイルの companion(§14.22)、companion を持つ型の `Val.of`(§14.23)、型引数を書かない `Val.of`(§14.24)、`Val` の綴り(§14.13)、欠けている disable コメント(§14.14)、効いていない disable コメント(§14.15)、ファイル全体の disable(§14.16)、`--no-` を受けない規則(§14.17)、指示についての規則の見せ方(§14.18)、oxlint の版と設定の正本(§14.19)、LSP でのホスト統合テスト(§14.20、撤回)、Trait の宣言・実装・`dyn` の構文追跡(§15.1) - **§15 v2 候補** - **15.1 `Trait`** `Final` マーカーと 1 段の `impl`、交差する trait ブランドと型引数だけで落とす `|`(却下したタプル)、`Self` マーカーと戻り値禁止、`dyn`(`Box` 相当)、却下した WeakMap ディスパッチ、需要と `dyn` を落とせる形の却下、experimental subpath(却下した機能ごとの subpath)、`impl` のコールバック形(引数は実装済みの final だけ、却下した実装側 companion) - - **15.2 `Enum`** §7.4 の見直し。Variant をレコードに宣言して union を導出、ブランドの導出、タグ名のカスタムと `tag-mismatch`、companion に置く `match`、ts-pattern との線引き、型を確かめた記録(共通フィールド、`match` の型引数、`VariantOf` の表示、却下した戻り値の型引数・自由関数の `match`・Val のレコード、Trait の実装、タグ名を `Tag<…>` で渡すこと、トップレベルの条件型が宣言出力を壊すこと)、実装して分かったこと(Fault の置き場所、Variant 1 個の禁止、`then` を 3 箇所で落とす、宣言出力の CI、variance 測定と型コスト)、Variant ごとの seal と union の `seal`(入口を 2 つに分ける、builder を callback で渡す、`impl` で鎖を閉じる、却下した値の形)、Enum 自身のコンストラクタの撤回、`implVariant` を Variant ごとの鎖にしたこと、steps を枠そのものにしたこと、タグ名の渡し方を変える 4 案の却下、companion を渡さない形(val.ts の門を共有する、`PayloadKeys` の分配、入口 2 つぶんのテスト) + - **15.2 `Enum`** §7.4 の見直し。Variant をレコードに宣言して union を導出、ブランドの導出、タグ名のカスタムと `tag-mismatch`、companion に置く `match`、ts-pattern との線引き、型を確かめた記録(共通フィールド、`match` の型引数、`VariantOf` の表示、却下した戻り値の型引数・自由関数の `match`・Val のレコード、Trait の実装、タグ名を `Tag<…>` で渡すこと、トップレベルの条件型が宣言出力を壊すこと)、実装して分かったこと(Fault の置き場所、Variant 1 個の禁止、`then` を 3 箇所で落とす、宣言出力の CI、variance 測定と型コスト)、Variant ごとの seal と union の `seal`(入口を 2 つに分ける、builder を callback で渡す、`impl` で鎖を閉じる、却下した値の形)、`seal` の引数を排他的にし sealer の呼び出しをやめたこと、タグだけで分ける `[Tag].match` と `[Tag].key`(キーを文字列にする理由、却下した名前)、`implVariant` を Variant ごとの鎖にしたこと、steps を枠そのものにしたこと、タグ名の渡し方を変える 4 案の却下、companion を渡さない形(val.ts の門を共有する、`PayloadKeys` の分配、入口 2 つぶんのテスト) - **15.3 `path`** seal をまたぐ patch の合成。`abort` を合成側に置く判断、ハンドラが最終段である理由(HKT)、`glue` の `open` / `close`、`each` / `where`、却下した `deepPatch` - **15.4 `.impl` のコールバック形** 自分の companion を参照すると推論が回らない(TS7022)。contextual typing がコールバック越しでも効くことの実測、`implTrait` も callback、予算、却下したメンバのカリー化と、下流の `.impl` を型で塞ぐ案。**鎖は 2026-09-18 に閉じた**(1 回だけ、兄弟は注釈で呼ぶ、Trait の実装の受け手を `Tr` に、却下した型レベルの案内と lint 規則) - **§16 予算の責務** バンドルと型を別のスクリプトに割る。宣言のバイト数を type-perf へ、予算を 64 kB に上げた理由 @@ -5619,7 +5619,7 @@ Variant の数では増えない。予算は 45,000 に置いた。 #### Variant ごとの seal と union の `seal`、2026-09-16 実装 -**2026-10-14: union の `seal` は撤回した。**「Enum 自身のコンストラクタを撤回」を見る。 +**2026-10-14: `seal` の引数を排他的にし、sealer の呼び出しをやめた。**下の「`seal` の引数を排他的にし、sealer の呼び出しをやめる」を見る。 **動機は境界の分岐。**タグ付きのペイロードが来る場所で、利用者は今どの Variant かを自分で見て、対応する コンストラクタを呼んでいる。`Shape.seal(payload)` があれば、タグで枠を引くのはライブラリの仕事になる。 @@ -5710,14 +5710,11 @@ Variant の枠が Val の companion なので追加の実装が要らない。 - **予算。**type-perf は 45,000 から 90,000 へ。Variant 1 個あたりは約 1,100 で線形、残りは variance 測定の定数。bundle-size は `Val` + `Enum` で 1.10 kB から 1.28 kB gzip、予算 1.63 kB は動かさない -#### Enum 自身のコンストラクタを撤回、2026-10-14 +#### `seal` の引数を排他的にし、sealer の呼び出しをやめる、2026-10-14 -**`Shape(payload)` と `Shape.seal(payload)` を消した。**値は Variant のコンストラクタだけが作る。Rust の -enum も Variant ごとに作る(`Command::New { … }`)。 - -**タグが union だと、他の Variant のフィールドが値に残る。**引数の型 `SealedPayload` は Variant ごとの -タグ付き payload の union である。TypeScript は、判別子が union のオブジェクトでも、判別子の各値について -代入できれば受け入れる。全 Variant のフィールドを持つオブジェクトがこれで通る。 +**タグが union だと、他の Variant のフィールドが値に残っていた。**引数の型 `SealedPayload` は Variant +ごとのタグ付き payload の union である。TypeScript は、判別子が union のオブジェクトでも、判別子の各値に +ついて代入できれば受け入れる。全 Variant のフィールドを持つオブジェクトがこれで通る。 ```ts Command.seal({ _tag: TAGS[name] ?? "Invalid", count: Number(argument), message: "…" }); @@ -5727,15 +5724,102 @@ Command.seal({ _tag: TAGS[name] ?? "Invalid", count: Number(argument), message: 型の上では `New` に `message` は無いが、値には入っていて、`equals` と JSON に出る。proxy は Variant の フィールドを知らないので、実行時に落とせない。 -**却下: 引数を Variant ごとに排他的な union にする。**他の Variant にしかないキーを `never` にすれば、 -上の呼び出しは型エラーになる。それでもタグを実行時に決める呼び出し側は、タグで分岐して Variant ごとの -payload を作ることになる。分岐を書くなら、その先で Variant のコンストラクタを呼べば足りる。 +**引数を Variant ごとに排他的にした。**`Exclusive` は、各 Variant のタグ付き payload に、他の Variant +にしかないキーを `?: never` で足す。 + +- タグが union で全フィールドを持つオブジェクトは型エラーになる。フレッシュでなくても同じ +- タグが 1 つでも、他の Variant のフィールドを持つフレッシュでないオブジェクトは型エラーになる。過剰 + プロパティ検査はここに届かない +- 2 つの Variant が共有するフィールドは `never` にしない。全 Variant のフィールドから、自分のフィールドを + 引いたものだけを `never` にする +- スキーマのライブラリが返す、タグと対応の取れた union は通る + +残るのは 2 つ。どの Variant にも無いキーを持つフレッシュでないオブジェクトは通る。Val のコンストラクタと +同じである。`exactOptionalPropertyTypes` が無いと `message: undefined` を書けて、キーだけが値に残る。 + +Enum の `implSeal` の引数 `SealedPayload` は変えていない。そこに届くのは、Variant のコンストラクタが +作った payload だけである。 + +**sealer の呼び出し(`Shape(payload)`)をやめ、両方の形を `Shape.seal` に揃えた。**呼び出しには名前が無く、 +何をするのか読み取れない。Variant の `.seal` がタグ付きの payload を 1 つの Variant について受け取るのと +揃う。 + +**経緯。**一度は `Shape(payload)` も `Shape.seal` も消し、値は Variant のコンストラクタだけが作る形にした。 +Rust の enum も Variant ごとに作る。スキーマのライブラリがタグで分けた payload では、済んだ分岐を +`[Tag].match` でもう一度書くことになるので、排他的な引数で `seal` を戻した。 + +この PR 全体(`[Tag]` を含む)で、enum の instantiation は 32,653 → 33,253、`index.d.mts` は 59.48 → +61.11 kB、Enum を含む production gzip は 1.27 kB のまま。 + +#### タグだけで分ける `[Tag].match`、2026-10-14 実装 + +**タグが payload と別に届くとき、Variant を選ぶ入口。**フォームの選択欄やコマンドライン引数がそうである。 +`seal` は payload の中にタグが要るので、ここでは使えない。この分岐は避けられない。payload の型がタグの値で決まるので、別々に持っているタグと payload を対応づけるには +場合分けが要る。Rust でも `match name { "new" => Command::New { … }, … }` と書く。選べるのは分岐を書く +場所だけで、`match` と同じ形で Enum の中に置けるようにした。 + +```ts +Command[Tag].match(tag, { + New: () => Command.New({ count: Number(argument) }), + Check: () => Command.Check({}), +}); +``` -- `seal` は Variant 名として予約したまま。proxy も `undefined` を返す。緩めるのは後方互換 -- Enum の `implSeal`(共有フィールドの検査)と Variant の `.seal` は残る。`SealedPayload` は Enum の seal の - 引数として残る -- enum の instantiation は 32,653 → 32,357、`index.d.mts` は 59.48 → 58.56 kB、Enum を含む production - gzip は 1.27 → 1.24 kB +- **ハンドラは引数を取らず、Variant のコンストラクタを自分で呼ぶ。**オブジェクトリテラルをコンストラクタに + 直接渡すので、他の Variant のフィールドは過剰プロパティ検査で落ちる +- **引数のタグが取りうる値すべてにハンドラが要る。**それ以外のキーは `"the tag never takes this value"` で + 落とす(`Only` と同じくパラメータ位置、§11.1) +- 戻り値はハンドラの戻り値の union。`match` と同じ理由(§15.2 の 3) +- 実行時は `handlers[tag]()`。proxy が Variant の名前を知らなくても書ける + +**Variant を足すと、ここが型エラーになる。**`if` や `switch` でタグを分けた箇所は、`assertNever` を +書かない限り黙って素通りする。production gzip で約 20 B(Enum を含む 1.24 → 1.26 kB)を払う。同じ網羅性は +`satisfies Record Shape>` でも書けるが、思いつきにくく、タグ名をカスタムすると +書き換えが要り、戻り値の型も自分で書く。 + +**却下: ハンドラが payload を返し、入口がタグを付けて seal する。**`make(tag, { New: () => ({ count }) })` +の形。アロー関数の戻り値の式には過剰プロパティ検査が掛からないので、`New: () => ({ count: 1, message: "m" })` +が通る。余計なフィールドを防ぐという目的を果たせない。 + +#### キーは文字列 `Tag = "__valof_tag"` + +`Tag` を値としても export し、companion のそのキーに `{ match, key }` を置く。 + +- **`Command[Tag].match` は対象(Command のタグ)が動詞の前に来る。**`Command.match` と語順が揃う +- **値の `Tag` は型の `Tag<"kind">` と同じ名前。**import 1 つで両方が入る +- **予約は増えない。**`__valof_${string}` は既に Variant にもメンバにも使えない。`tag` という Variant は + 今までどおり書ける +- **キーが文字列リテラルなので、利用者の宣言出力にキーがそのまま書かれる。**値の名前が要らない。 + プロパティとして宣言されるので、`noUncheckedIndexedAccess` でも `undefined` が付かない。TS 5.9 / 6.0 / 7.0 + で、`Cmd[Tag]` を export する利用者側の宣言出力を確かめた +- valof が複数入っても、文字列は同じ値なので動く +- 名前は実行時にあるキーの `__valof_traits` に合わせ、`internal` も `phantom` も入れない + +`Command.__valof_tag.match(…)` と直接書いても動く。`__valof_` で始まるキーを直接書くメンバーアクセスを +指摘する lint の規則を、別の PR で足す。Enum に限らず `__valof_traits` も対象になる。 + +**却下した名前と形** + +- **`match` のオーバーロード。**値を取る `match` とタグを取る `match` ではハンドラの形が違い(値を受け取る / + 何も受け取らない)、型エラーの文面が読みにくくなる +- **`Command.match.tag(…)`。**予約は増えないが、対象が動詞の後ろに来る +- **`Command.tag.match(…)`。**`tag` が Variant に使えなくなる。タグ名を型引数のマーカー(`Tag`)にしたのは + `tag` という Variant を書けるようにするためだった +- **`Command._tag.match(…)`(タグのフィールド名をキーにする)。**宣言出力も `noUncheckedIndexedAccess` も + 通るが、タグ名と同じ名前の Variant が書けなくなり、`_tag` が内部用に見える。次点だった +- **`Command.matchTag(…)`。**固定の名前を 1 つ予約する +- **`Tag` を `unique symbol` にする。**型が展開されて出力される companion で、`Tag` を import していない + ファイルが TS4023 になる。`Tag` を export していても起きる。§2.1 と §15.2 で却下したのと同じ問題 +- **`Tag` を phantom を重ねた symbol(`symbol & TagSymbolPhantom`)にする。**`unique symbol` ではないので + インデックスシグネチャで持つことになり、`noUncheckedIndexedAccess` で `Command[Tag]` に `undefined` が付く + +値の名前なしで宣言出力に書けて、しかも 1 つの値に決まるキーは、文字列リテラルだけである。 + +#### `[Tag].key` はタグのフィールド名 + +`Command[Tag].key` は `"_tag"`、`Tag<"kind">` なら `"kind"` を、`TagOf` のリテラルの型で返す。スキーマの +ライブラリに判別子の名前を渡すとき(`z.discriminatedUnion(Command[Tag].key, […])`)、文字列を二重に +書かずに済む。proxy はタグ名を実行時に持っているので、返すだけで足りる。 #### `implVariant` は Variant ごとの鎖、2026-09-18 実装 From d8eeb92be4b17d4b718c0f91984824c192ff9c49 Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:32:03 +0900 Subject: [PATCH 08/24] docs: align the `[Tag].match` example with the book --- src/enum.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/enum.ts b/src/enum.ts index 756e4fe..b938d4d 100644 --- a/src/enum.ts +++ b/src/enum.ts @@ -465,13 +465,13 @@ type Yields = X extends { create: (...args: never[]) => infer R } type ByTag = { /** * Dispatches on a tag before there is a value, as when a tag arrives apart from its payload. Every - * tag the argument can hold needs a handler. A handler takes nothing and calls its variant's - * constructor, which checks the payload literal for fields the variant does not hold. + * tag the argument's type allows needs a handler. A handler takes no argument and calls its + * variant's constructor, which checks the payload literal for fields the variant does not hold. * * ```ts - * Command[Tag].match(tag, { - * New: () => Command.New({ count }), - * Check: () => Command.Check({}), + * Shape[Tag].match(form.kind, { + * Circle: () => Shape.Circle({ r: form.size / 2 }), + * Square: () => Shape.Square({ side: form.size }), * }); * ``` */ From 8f64972a64dde0303e282af28fb7a19e33c11aa4 Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:43:50 +0900 Subject: [PATCH 09/24] feat: seal an enum by calling its sealer, as a Val's --- src/enum.ts | 109 ++++++++++++++++++++++----------------------- tests/enum.test.ts | 18 ++++---- 2 files changed, 63 insertions(+), 64 deletions(-) diff --git a/src/enum.ts b/src/enum.ts index b938d4d..ccbe257 100644 --- a/src/enum.ts +++ b/src/enum.ts @@ -527,7 +527,8 @@ export type EnumCompanion< }; /** - * The same for `Enum.sealer`, where every variant is callable. + * The same for `Enum.sealer`, where the companion and every variant are callable. Calling the + * companion is {@link EnumCompanion.seal}. * * Inferred, not written: it is exported so your own declarations can name it. * @@ -537,12 +538,11 @@ export type EnumSealed< E extends AnyEnum, VM = Record, M = Record, -> = SealerConstructors & +> = EnumSeal> & + SealerConstructors & Omit> & { /** See {@link EnumCompanion.match}. */ match: Match; - /** See {@link EnumCompanion.seal}. */ - seal: EnumSeal>; readonly [Tag]: ByTag; /** Every member `implTrait` registered. Read by `Trait`'s `dyn`. */ readonly __valof_traits: Members; @@ -752,58 +752,57 @@ const state = ( nextOpen: boolean, ): object => state(tag, callable, nextBuilds, nextMembers, nextTraits, nextSeal, nextOpen); - const self: object = new Proxy( - {}, - { - get(_, key) { - // A symbol reaches here from `await`, `JSON.stringify` and every other protocol read, and - // `then` would make the companion a thenable. Both would otherwise resolve to a frame, - // since the proxy has no list of variant names to check against. A `then` variant is an - // error at the declaration, like a `then` member anywhere else. - if (typeof key !== "string" || key === "then") return undefined; - if (Object.hasOwn(members, key)) return members[key]; - if (key === "match") { - return ( - value: Record, - handlers: Record unknown>, - ) => handlers[value[tag] as string]!(value); - } - if (key === "seal") return enter; - if (key === Tag) return byTag; - if (key === "__valof_traits") return traits; - if (key === "impl") { - // One call, which closes every step: what it returns carries no `impl` of its own. - return open - ? (fns: Payload = {}) => step(builds, { ...members, ...fns }, traits, seal, false) - : undefined; - } - if (key === "implVariant") { - return open - ? (name: string, build: (sealer: object) => object) => - step({ ...builds, [name]: build }, members, traits, seal, true) - : undefined; - } - if (key === "implSeal") { - return open && !callable - ? (custom: Seal) => step(builds, members, traits, custom, true) - : undefined; - } - if (key === "implTrait") { - // The trait's own members and the enum's, merged the way `val.ts` merges them. - return open - ? ( - trait: { __valof_shared?: Record }, - fns: Record = {}, - ) => { - const grown = { ...traits, ...(trait.__valof_shared ?? trait), ...fns }; - return step(builds, { ...members, ...grown }, grown, seal, true); - } - : undefined; - } - return frame(key); - }, + const self: object = new Proxy(callable ? () => undefined : {}, { + apply: (_, __, args: [Payload]) => enter(args[0]), + get(_, key) { + // A symbol reaches here from `await`, `JSON.stringify` and every other protocol read, and + // `then` would make the companion a thenable. Both would otherwise resolve to a frame, + // since the proxy has no list of variant names to check against. A `then` variant is an + // error at the declaration, like a `then` member anywhere else. + if (typeof key !== "string" || key === "then") return undefined; + if (Object.hasOwn(members, key)) return members[key]; + if (key === "match") { + return ( + value: Record, + handlers: Record unknown>, + ) => handlers[value[tag] as string]!(value); + } + // A sealer's entry is the call, so the name stays reserved and resolves to nothing. + if (key === "seal") return callable ? undefined : enter; + if (key === Tag) return byTag; + if (key === "__valof_traits") return traits; + if (key === "impl") { + // One call, which closes every step: what it returns carries no `impl` of its own. + return open + ? (fns: Payload = {}) => step(builds, { ...members, ...fns }, traits, seal, false) + : undefined; + } + if (key === "implVariant") { + return open + ? (name: string, build: (sealer: object) => object) => + step({ ...builds, [name]: build }, members, traits, seal, true) + : undefined; + } + if (key === "implSeal") { + return open && !callable + ? (custom: Seal) => step(builds, members, traits, custom, true) + : undefined; + } + if (key === "implTrait") { + // The trait's own members and the enum's, merged the way `val.ts` merges them. + return open + ? ( + trait: { __valof_shared?: Record }, + fns: Record = {}, + ) => { + const grown = { ...traits, ...(trait.__valof_shared ?? trait), ...fns }; + return step(builds, { ...members, ...grown }, grown, seal, true); + } + : undefined; + } + return frame(key); }, - ); + }); return self; }; diff --git a/tests/enum.test.ts b/tests/enum.test.ts index e3a8f38..d2ac9af 100644 --- a/tests/enum.test.ts +++ b/tests/enum.test.ts @@ -126,13 +126,13 @@ describe("match on a tag alone", () => { }); }); -describe("seal a payload by its tag", () => { +describe("a sealer seals a payload by its tag", () => { type Command = Enum<"Command", { New: { count: number }; Fail: { message: string } }>; const Command = Enum.sealer(); type Wire = SealedPayload; test("builds the variant the tag names, and returns the variants' union", () => { - const parse = (wire: Wire) => Command.seal(wire); + const parse = (wire: Wire) => Command(wire); expectTypeOf(parse).returns.toEqualTypeOf(); expect(parse({ _tag: "New", count: 2 })).toEqual(Command.New({ count: 2 })); expect(parse({ _tag: "Fail", message: "m" })).toEqual(Command.Fail({ message: "m" })); @@ -140,20 +140,20 @@ describe("seal a payload by its tag", () => { // TypeScript accepts a union tag wherever each of its values fits, so the payload below would // otherwise build a `New` that holds `message`. - test("a payload holding another variant's field is rejected, fresh or not", () => { + test("a payload holding another variant's field is rejected, as a literal or through a variable", () => { const tag: "New" | "Fail" = Math.random() > 0.5 ? "New" : "Fail"; // @ts-expect-error Types of property '_tag' are incompatible - Command.seal({ _tag: tag, count: 2, message: "m" }); + Command({ _tag: tag, count: 2, message: "m" }); // No excess property check reaches an object that is not fresh, even with one tag. const held = { _tag: "New" as const, count: 2, message: "m" }; // @ts-expect-error Types of property 'message' are incompatible - Command.seal(held); + Command(held); }); test("a field two variants share is not another variant's", () => { type Mark = Enum<"Mark", { Pin: { at: number }; Flag: { at: number }; Note: { text: string } }>; const Mark = Enum.sealer(); - expect(Mark.seal({ _tag: "Pin", at: 1 })).toEqual(Mark.Pin({ at: 1 })); + expect(Mark({ _tag: "Pin", at: 1 })).toEqual(Mark.Pin({ at: 1 })); }); }); @@ -715,9 +715,9 @@ describe("the chain closes", () => { }); describe("the companion is a proxy", () => { - test("a sealer is not callable: a tagged payload goes to `seal`", () => { - // @ts-expect-error a sealer is not callable - expect(() => Shape({ r: 1, _tag: "Circle" })).toThrow(TypeError); + test("a sealer's entry is the call, so `seal` resolves to nothing", () => { + expectTypeOf(Shape).not.toHaveProperty("seal"); + expect((Shape as unknown as Record)["seal"]).toBeUndefined(); }); test("so a symbol and `then` come back undefined", async () => { From 85b43cd4d5c6f36f7124d0743f6e5283d1ff70fe Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:45:12 +0900 Subject: [PATCH 10/24] docs: build an enum from a tagged payload by calling its sealer --- docs/src/api.md | 2 +- docs/src/enums.md | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/src/api.md b/docs/src/api.md index af782fe..ceb31c4 100644 --- a/docs/src/api.md +++ b/docs/src/api.md @@ -66,7 +66,7 @@ re-export a companion. | `Enum.companion(tag?)` | the same, for an enum with a seal of its own | | `E.match(value, handlers)` | dispatches on the tag, exhaustively | | `E[Tag].match(tag, handlers)` | the same on a tag alone, before there is a value | -| `E.seal(payload)` | builds the variant the payload's tag names | +| `E(payload)` / `E.seal(payload)` | builds the variant the payload's tag names | | `E[Tag].key` | the tag field's name | | `E[Variant](payload)` | builds that variant, writing the tag | | `E[Variant].create(payload)` | the same on a companion, through its seal | diff --git a/docs/src/enums.md b/docs/src/enums.md index a09e66a..4f21e1c 100644 --- a/docs/src/enums.md +++ b/docs/src/enums.md @@ -35,10 +35,10 @@ Call a variant through the companion. `const { Circle } = Shape` works, and a re 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. `Shape.seal` takes a payload that already carries the tag, such as one a schema library -parsed. It reads the tag and passes the payload to that variant's constructor. A field only another -variant declares is a type error, even where the tag is a union. When the tag arrives alone, see -[Build from a tag](#build-from-a-tag). +constructor. `Shape(payload)` takes a payload that already carries the tag, such as one a schema +library parsed. It reads the tag and passes the payload to that variant's constructor. A field only +another variant declares is a type error, even where the tag is a union. When the tag arrives alone, +see [Build from a tag](#build-from-a-tag). A variant is a Val. It patches, it compares, and it nests. A patch cannot reach the tag, so it cannot switch variants. From cbd8d40d1f3b2dcd41c778b62852a3ec14b39a55 Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:45:13 +0900 Subject: [PATCH 11/24] docs(notes): keep the enum's two entries, as a Val's --- notes/design.md | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/notes/design.md b/notes/design.md index 3071d1c..2b0bbf4 100644 --- a/notes/design.md +++ b/notes/design.md @@ -36,7 +36,7 @@ import { Val } from "valof"; - **§14 valof-lint** companion のメンバが静的解析から見えない問題。パーサ選定、同梱の判断、却下した ts-morph(§14.5)、カスタム equals を持つ子の規則(§14.7、撤回)、ルールの表現と構成(§14.8)、エディタ統合(§14.9、overlay まで実装)、テストの穴(§14.10)、テストの置き場所(§14.11)、型名と一致しないブランド(§14.12)、Val / Trait の 2 つ目の名前(§14.25)、却下した自己参照でない `Rec` の規則(§14.26)、`unused-member` と型が拒む名前(§14.27、型で結んだ lint の名前)、型名と一致しない companion(§14.21)、型と別ファイルの companion(§14.22)、companion を持つ型の `Val.of`(§14.23)、型引数を書かない `Val.of`(§14.24)、`Val` の綴り(§14.13)、欠けている disable コメント(§14.14)、効いていない disable コメント(§14.15)、ファイル全体の disable(§14.16)、`--no-` を受けない規則(§14.17)、指示についての規則の見せ方(§14.18)、oxlint の版と設定の正本(§14.19)、LSP でのホスト統合テスト(§14.20、撤回)、Trait の宣言・実装・`dyn` の構文追跡(§15.1) - **§15 v2 候補** - **15.1 `Trait`** `Final` マーカーと 1 段の `impl`、交差する trait ブランドと型引数だけで落とす `|`(却下したタプル)、`Self` マーカーと戻り値禁止、`dyn`(`Box` 相当)、却下した WeakMap ディスパッチ、需要と `dyn` を落とせる形の却下、experimental subpath(却下した機能ごとの subpath)、`impl` のコールバック形(引数は実装済みの final だけ、却下した実装側 companion) - - **15.2 `Enum`** §7.4 の見直し。Variant をレコードに宣言して union を導出、ブランドの導出、タグ名のカスタムと `tag-mismatch`、companion に置く `match`、ts-pattern との線引き、型を確かめた記録(共通フィールド、`match` の型引数、`VariantOf` の表示、却下した戻り値の型引数・自由関数の `match`・Val のレコード、Trait の実装、タグ名を `Tag<…>` で渡すこと、トップレベルの条件型が宣言出力を壊すこと)、実装して分かったこと(Fault の置き場所、Variant 1 個の禁止、`then` を 3 箇所で落とす、宣言出力の CI、variance 測定と型コスト)、Variant ごとの seal と union の `seal`(入口を 2 つに分ける、builder を callback で渡す、`impl` で鎖を閉じる、却下した値の形)、`seal` の引数を排他的にし sealer の呼び出しをやめたこと、タグだけで分ける `[Tag].match` と `[Tag].key`(キーを文字列にする理由、却下した名前)、`implVariant` を Variant ごとの鎖にしたこと、steps を枠そのものにしたこと、タグ名の渡し方を変える 4 案の却下、companion を渡さない形(val.ts の門を共有する、`PayloadKeys` の分配、入口 2 つぶんのテスト) + - **15.2 `Enum`** §7.4 の見直し。Variant をレコードに宣言して union を導出、ブランドの導出、タグ名のカスタムと `tag-mismatch`、companion に置く `match`、ts-pattern との線引き、型を確かめた記録(共通フィールド、`match` の型引数、`VariantOf` の表示、却下した戻り値の型引数・自由関数の `match`・Val のレコード、Trait の実装、タグ名を `Tag<…>` で渡すこと、トップレベルの条件型が宣言出力を壊すこと)、実装して分かったこと(Fault の置き場所、Variant 1 個の禁止、`then` を 3 箇所で落とす、宣言出力の CI、variance 測定と型コスト)、Variant ごとの seal と union の `seal`(入口を 2 つに分ける、builder を callback で渡す、`impl` で鎖を閉じる、却下した値の形)、`seal` の引数を排他的にしたこと、タグだけで分ける `[Tag].match` と `[Tag].key`(キーを文字列にする理由、却下した名前)、`implVariant` を Variant ごとの鎖にしたこと、steps を枠そのものにしたこと、タグ名の渡し方を変える 4 案の却下、companion を渡さない形(val.ts の門を共有する、`PayloadKeys` の分配、入口 2 つぶんのテスト) - **15.3 `path`** seal をまたぐ patch の合成。`abort` を合成側に置く判断、ハンドラが最終段である理由(HKT)、`glue` の `open` / `close`、`each` / `where`、却下した `deepPatch` - **15.4 `.impl` のコールバック形** 自分の companion を参照すると推論が回らない(TS7022)。contextual typing がコールバック越しでも効くことの実測、`implTrait` も callback、予算、却下したメンバのカリー化と、下流の `.impl` を型で塞ぐ案。**鎖は 2026-09-18 に閉じた**(1 回だけ、兄弟は注釈で呼ぶ、Trait の実装の受け手を `Tr` に、却下した型レベルの案内と lint 規則) - **§16 予算の責務** バンドルと型を別のスクリプトに割る。宣言のバイト数を type-perf へ、予算を 64 kB に上げた理由 @@ -5619,7 +5619,7 @@ Variant の数では増えない。予算は 45,000 に置いた。 #### Variant ごとの seal と union の `seal`、2026-09-16 実装 -**2026-10-14: `seal` の引数を排他的にし、sealer の呼び出しをやめた。**下の「`seal` の引数を排他的にし、sealer の呼び出しをやめる」を見る。 +**2026-10-14: `seal` の引数を排他的にした。**下の「`seal` の引数を排他的にする」を見る。 **動機は境界の分岐。**タグ付きのペイロードが来る場所で、利用者は今どの Variant かを自分で見て、対応する コンストラクタを呼んでいる。`Shape.seal(payload)` があれば、タグで枠を引くのはライブラリの仕事になる。 @@ -5710,7 +5710,7 @@ Variant の枠が Val の companion なので追加の実装が要らない。 - **予算。**type-perf は 45,000 から 90,000 へ。Variant 1 個あたりは約 1,100 で線形、残りは variance 測定の定数。bundle-size は `Val` + `Enum` で 1.10 kB から 1.28 kB gzip、予算 1.63 kB は動かさない -#### `seal` の引数を排他的にし、sealer の呼び出しをやめる、2026-10-14 +#### `seal` の引数を排他的にする、2026-10-14 **タグが union だと、他の Variant のフィールドが値に残っていた。**引数の型 `SealedPayload` は Variant ごとのタグ付き payload の union である。TypeScript は、判別子が union のオブジェクトでも、判別子の各値に @@ -5740,16 +5740,17 @@ Command.seal({ _tag: TAGS[name] ?? "Invalid", count: Number(argument), message: Enum の `implSeal` の引数 `SealedPayload` は変えていない。そこに届くのは、Variant のコンストラクタが 作った payload だけである。 -**sealer の呼び出し(`Shape(payload)`)をやめ、両方の形を `Shape.seal` に揃えた。**呼び出しには名前が無く、 -何をするのか読み取れない。Variant の `.seal` がタグ付きの payload を 1 つの Variant について受け取るのと -揃う。 +**入口は Val と同じ 2 つのまま。**`Enum.sealer` は呼び出し(`Shape(payload)`)、`Enum.companion` は +`Shape.seal(payload)`。`Val.sealer` が `User(value)`、`Val.companion` が `User.seal(value)` なのと対応する。 +不健全さの原因は引数の型で、置き場所ではない。 **経緯。**一度は `Shape(payload)` も `Shape.seal` も消し、値は Variant のコンストラクタだけが作る形にした。 Rust の enum も Variant ごとに作る。スキーマのライブラリがタグで分けた payload では、済んだ分岐を -`[Tag].match` でもう一度書くことになるので、排他的な引数で `seal` を戻した。 +`[Tag].match` でもう一度書くことになるので、排他的な引数で戻した。その途中で両方の形を `Shape.seal` に +揃えたが、Val の sealer には `.seal` が無く、同じ操作に名前が 2 つできるので、呼び出しに戻した。 -この PR 全体(`[Tag]` を含む)で、enum の instantiation は 32,653 → 33,253、`index.d.mts` は 59.48 → -61.11 kB、Enum を含む production gzip は 1.27 kB のまま。 +この PR 全体(`[Tag]` を含む)で、enum の instantiation は 32,653 → 33,264、`index.d.mts` は 59.48 → +61.18 kB、Enum を含む production gzip は 1.27 → 1.29 kB。 #### タグだけで分ける `[Tag].match`、2026-10-14 実装 From 63aea7aff196aaabe2ca9a8c6c01c08d47da4468 Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:53:04 +0900 Subject: [PATCH 12/24] docs: build a payment from a form's method in the `[Tag].match` example --- docs/src/enums.md | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/docs/src/enums.md b/docs/src/enums.md index 4f21e1c..b75455a 100644 --- a/docs/src/enums.md +++ b/docs/src/enums.md @@ -84,19 +84,26 @@ as ts-pattern fits. `_tag` is real data, so `.with({ _tag: "Circle" }, …)` alr ## Build from a tag When the tag arrives without its payload, `[Tag].match` selects the handler from the tag alone. Here -a form picks the shape in one field and its size in another: +a form has a field for every payment method, and the chosen method reads only its own: ```ts import { Enum, Tag } from "valof/experimental"; - -type Shape = Enum<"Shape", { Circle: { r: number }; Square: { side: number } }>; -const Shape = Enum.sealer(); -declare const form: { kind: "Circle" | "Square"; size: number }; // ---cut--- -const shape = Shape[Tag].match(form.kind, { - Circle: () => Shape.Circle({ r: form.size / 2 }), - Square: () => Shape.Square({ side: form.size }), +type Payment = Enum< + "Payment", + { Cash: Record; Card: { number: string }; Transfer: { account: string } } +>; +const Payment = Enum.sealer(); +declare const form: { method: "Cash" | "Card" | "Transfer"; card: string; account: string }; + +const payment = Payment[Tag].match(form.method, { + Cash: () => Payment.Cash({}), + Card: () => Payment.Card({ number: form.card }), + Transfer: () => Payment.Transfer({ account: form.account }), }); + +// @ts-expect-error a Cash payment would keep `number` and `account` +Payment({ _tag: form.method, number: form.card, account: form.account }); ``` Every tag the argument's type allows needs a handler, so adding a variant to the declaration fails From 65471679e1b2ff7a2ea4eb67e6e8a1b75078d0a9 Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:04:50 +0900 Subject: [PATCH 13/24] docs: use the payment form in the `[Tag].match` JSDoc --- src/enum.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/enum.ts b/src/enum.ts index ccbe257..ede26b5 100644 --- a/src/enum.ts +++ b/src/enum.ts @@ -469,9 +469,10 @@ type ByTag = { * variant's constructor, which checks the payload literal for fields the variant does not hold. * * ```ts - * Shape[Tag].match(form.kind, { - * Circle: () => Shape.Circle({ r: form.size / 2 }), - * Square: () => Shape.Square({ side: form.size }), + * Payment[Tag].match(form.method, { + * Cash: () => Payment.Cash({}), + * Card: () => Payment.Card({ number: form.card }), + * Transfer: () => Payment.Transfer({ account: form.account }), * }); * ``` */ From 969bb6a4cd97bd4fc2dbbfb13bf898d85bb22987 Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:05:38 +0900 Subject: [PATCH 14/24] docs(notes): record why the enum keeps its entry --- notes/design.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/notes/design.md b/notes/design.md index 2b0bbf4..2310772 100644 --- a/notes/design.md +++ b/notes/design.md @@ -5749,6 +5749,18 @@ Rust の enum も Variant ごとに作る。スキーマのライブラリがタ `[Tag].match` でもう一度書くことになるので、排他的な引数で戻した。その途中で両方の形を `Shape.seal` に 揃えたが、Val の sealer には `.seal` が無く、同じ操作に名前が 2 つできるので、呼び出しに戻した。 +**却下: 入口を消す(2 度目)。**タグと対応の切れた値は、どれも型エラーで止まる。値は壊れないので、消す +理由にならない。probe で確かめた。 + +- `const { _tag, ...rest } = shape; Shape({ _tag, ...rest })`。`_tag` が別の変数に出た時点で対応が切れる。 + 排他的でない引数でも型エラーだった。値を丸ごとスプレッドすれば(`Shape({ ...shape, id })`)通る +- `Omit, "id">`。`Omit` は union に分配せず、キーは `_tag` だけになる +- 全 Variant の欄を持つ平らなフォーム。`[Tag].match` で Variant ごとに欄を選ぶ + +入口が無いと、タグと対応の取れた payload(スキーマの出力、サーバーの JSON、メッセージ)を Variant に分ける +`switch` を手で書くことになる。`[Tag].match` のアームは引数を取らないので、payload は絞り込まれない。 +`switch` は Variant を足しても黙って素通りする。 + この PR 全体(`[Tag]` を含む)で、enum の instantiation は 32,653 → 33,264、`index.d.mts` は 59.48 → 61.18 kB、Enum を含む production gzip は 1.27 → 1.29 kB。 From 96cc2cce021154b72a23f975023dde1e0da912fc Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:20:30 +0900 Subject: [PATCH 15/24] docs: show which call builds an enum value from what arrives --- docs/src/enums.md | 44 +++++++++++++++++++++++++------------------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/docs/src/enums.md b/docs/src/enums.md index b75455a..e8bca73 100644 --- a/docs/src/enums.md +++ b/docs/src/enums.md @@ -35,10 +35,8 @@ Call a variant through the companion. `const { Circle } = Shape` works, and a re 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. `Shape(payload)` takes a payload that already carries the tag, such as one a schema -library parsed. It reads the tag and passes the payload to that variant's constructor. A field only -another variant declares is a type error, even where the tag is a union. When the tag arrives alone, -see [Build from a tag](#build-from-a-tag). +constructor. For a payload that carries its tag, or a tag that arrives alone, see +[Build a value](#build-a-value). A variant is a Val. It patches, it compares, and it nests. A patch cannot reach the tag, so it cannot switch variants. @@ -81,29 +79,37 @@ 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. -## Build from a tag +## Build a value -When the tag arrives without its payload, `[Tag].match` selects the handler from the tag alone. Here -a form has a field for every payment method, and the chosen method reads only its own: +An enum value can be built from three kinds of input, and each has its own call: + +| When you have | Use | +| ------------------------------------------------------------- | ------------------------------------------------------------------------- | +| a payload for a variant you already know | `Shape.Circle(payload)`, or `Shape.Circle.create(payload)` on a companion | +| a payload carrying its tag, such as a schema library's output | `Shape(payload)`, or `Shape.seal(payload)` on a companion | +| the tag alone, with the fields kept elsewhere | `Shape[Tag].match(tag, handlers)` | + +`Shape(payload)` reads the tag, selects that variant, and accepts only its fields. Passing a field +of another variant is a type error, even when the tag is a union. + +When the tag arrives alone, `[Tag].match` selects the handler from the tag. On a command line, the +first argument is the subcommand, and how to read the rest depends on it: ```ts import { Enum, Tag } from "valof/experimental"; // ---cut--- -type Payment = Enum< - "Payment", - { Cash: Record; Card: { number: string }; Transfer: { account: string } } +type Command = Enum< + "Command", + { new: { name: string }; rename: { from: string; to: string }; check: Record } >; -const Payment = Enum.sealer(); -declare const form: { method: "Cash" | "Card" | "Transfer"; card: string; account: string }; +const Command = Enum.sealer(); +declare const [subcommand, ...args]: ["new" | "rename" | "check", ...string[]]; -const payment = Payment[Tag].match(form.method, { - Cash: () => Payment.Cash({}), - Card: () => Payment.Card({ number: form.card }), - Transfer: () => Payment.Transfer({ account: form.account }), +const command = Command[Tag].match(subcommand, { + new: () => Command.new({ name: args[0] ?? "" }), + rename: () => Command.rename({ from: args[0] ?? "", to: args[1] ?? "" }), + check: () => Command.check({}), }); - -// @ts-expect-error a Cash payment would keep `number` and `account` -Payment({ _tag: form.method, number: form.card, account: form.account }); ``` Every tag the argument's type allows needs a handler, so adding a variant to the declaration fails From c7d9fc65456ba3768a4ada835b9d6ae653739eaf Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:25:39 +0900 Subject: [PATCH 16/24] docs: build a command from a normalized tag in the `[Tag].match` example --- docs/src/enums.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/src/enums.md b/docs/src/enums.md index e8bca73..4c43ff5 100644 --- a/docs/src/enums.md +++ b/docs/src/enums.md @@ -100,15 +100,16 @@ import { Enum, Tag } from "valof/experimental"; // ---cut--- type Command = Enum< "Command", - { new: { name: string }; rename: { from: string; to: string }; check: Record } + { New: { name: string }; Rename: { from: string; to: string }; Check: Record } >; const Command = Enum.sealer(); -declare const [subcommand, ...args]: ["new" | "rename" | "check", ...string[]]; +declare const tag: "New" | "Rename" | "Check"; // the subcommand, already normalized +declare const args: readonly string[]; // the arguments after it -const command = Command[Tag].match(subcommand, { - new: () => Command.new({ name: args[0] ?? "" }), - rename: () => Command.rename({ from: args[0] ?? "", to: args[1] ?? "" }), - check: () => Command.check({}), +const command = Command[Tag].match(tag, { + New: () => Command.New({ name: args[0] ?? "" }), + Rename: () => Command.Rename({ from: args[0] ?? "", to: args[1] ?? "" }), + Check: () => Command.Check({}), }); ``` From 1b81c6e97ce39fa2e82c42570cb8439dafa02133 Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:27:38 +0900 Subject: [PATCH 17/24] docs: read the command example's tag and arguments from one input --- docs/src/enums.md | 10 +++++----- src/enum.ts | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/src/enums.md b/docs/src/enums.md index 4c43ff5..587dd08 100644 --- a/docs/src/enums.md +++ b/docs/src/enums.md @@ -103,12 +103,12 @@ type Command = Enum< { New: { name: string }; Rename: { from: string; to: string }; Check: Record } >; const Command = Enum.sealer(); -declare const tag: "New" | "Rename" | "Check"; // the subcommand, already normalized -declare const args: readonly string[]; // the arguments after it +// The subcommand, already normalized to its tag, and the arguments after it. +declare const input: { tag: "New" | "Rename" | "Check"; args: readonly string[] }; -const command = Command[Tag].match(tag, { - New: () => Command.New({ name: args[0] ?? "" }), - Rename: () => Command.Rename({ from: args[0] ?? "", to: args[1] ?? "" }), +const command = Command[Tag].match(input.tag, { + New: () => Command.New({ name: input.args[0] ?? "" }), + Rename: () => Command.Rename({ from: input.args[0] ?? "", to: input.args[1] ?? "" }), Check: () => Command.Check({}), }); ``` diff --git a/src/enum.ts b/src/enum.ts index ede26b5..8ac1f4f 100644 --- a/src/enum.ts +++ b/src/enum.ts @@ -469,10 +469,10 @@ type ByTag = { * variant's constructor, which checks the payload literal for fields the variant does not hold. * * ```ts - * Payment[Tag].match(form.method, { - * Cash: () => Payment.Cash({}), - * Card: () => Payment.Card({ number: form.card }), - * Transfer: () => Payment.Transfer({ account: form.account }), + * Command[Tag].match(input.tag, { + * New: () => Command.New({ name: input.args[0] ?? "" }), + * Rename: () => Command.Rename({ from: input.args[0] ?? "", to: input.args[1] ?? "" }), + * Check: () => Command.Check({}), * }); * ``` */ From b82d74a18dd7b74cf13a7aaa5f2ee403c45e0192 Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:32:27 +0900 Subject: [PATCH 18/24] docs: tell the two enum matches apart by what they match on --- docs/src/api.md | 4 ++-- src/enum.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/src/api.md b/docs/src/api.md index ceb31c4..5bb3928 100644 --- a/docs/src/api.md +++ b/docs/src/api.md @@ -64,8 +64,8 @@ re-export a companion. | `Enum` | a closed set of variants, as one union | | `Enum.sealer(tag?)` | starts an enum whose variants are callable | | `Enum.companion(tag?)` | the same, for an enum with a seal of its own | -| `E.match(value, handlers)` | dispatches on the tag, exhaustively | -| `E[Tag].match(tag, handlers)` | the same on a tag alone, before there is a value | +| `E.match(value, handlers)` | matches on a variant's tag field, exhaustively | +| `E[Tag].match(tag, handlers)` | matches on a tag string, also exhaustively | | `E(payload)` / `E.seal(payload)` | builds the variant the payload's tag names | | `E[Tag].key` | the tag field's name | | `E[Variant](payload)` | builds that variant, writing the tag | diff --git a/src/enum.ts b/src/enum.ts index 8ac1f4f..0560a26 100644 --- a/src/enum.ts +++ b/src/enum.ts @@ -464,9 +464,9 @@ type Yields = X extends { create: (...args: never[]) => infer R } /** What an enum dispatches on a tag alone, under {@link Tag}. */ type ByTag = { /** - * Dispatches on a tag before there is a value, as when a tag arrives apart from its payload. Every - * tag the argument's type allows needs a handler. A handler takes no argument and calls its - * variant's constructor, which checks the payload literal for fields the variant does not hold. + * Dispatches on a tag that arrives without its payload. Every tag the argument's type allows needs + * a handler. A handler takes no argument and calls its variant's constructor, which checks the + * payload literal for fields the variant does not hold. * * ```ts * Command[Tag].match(input.tag, { From c90fd2f22381923b13b437404afb3de877699d79 Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:34:12 +0900 Subject: [PATCH 19/24] docs: describe the enum rows of the API table in the reader's terms --- docs/src/api.md | 52 ++++++++++++++++++++++++------------------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/docs/src/api.md b/docs/src/api.md index 5bb3928..b53e50b 100644 --- a/docs/src/api.md +++ b/docs/src/api.md @@ -59,32 +59,32 @@ re-export a companion. ### Enum -| | | -| ------------------------------------ | -------------------------------------------------------- | -| `Enum` | a closed set of variants, as one union | -| `Enum.sealer(tag?)` | starts an enum whose variants are callable | -| `Enum.companion(tag?)` | the same, for an enum with a seal of its own | -| `E.match(value, handlers)` | matches on a variant's tag field, exhaustively | -| `E[Tag].match(tag, handlers)` | matches on a tag string, also exhaustively | -| `E(payload)` / `E.seal(payload)` | builds the variant the payload's tag names | -| `E[Tag].key` | the tag field's name | -| `E[Variant](payload)` | builds that variant, writing the tag | -| `E[Variant].create(payload)` | the same on a companion, through its seal | -| `E[Variant].patch(value, patch)` | derives a variant, never reaching the tag | -| `.impl(fns?)` | adds members taking the union, and ends the chain | -| `.implVariant(N, sealer => …)` | builds one variant from its own steps | -| `.implSeal(seal)` | replaces the seal every variant passes | -| `.implTrait(Tr, fns?)` | implements a trait the enum declares | -| `.implTrait(fns)` | the same, for a trait that implements nothing of its own | -| `Tag` | names the tag field, intersected into `X` | -| `VariantOf` | the type of one variant | -| `SeedFor` | what that variant's constructor takes | -| `SealedPayload` | what the enum's seal takes, tag included | -| `VariantsOf` / `SharedOf` | the declared variants, and the shared fields | -| `NameOf` / `TagOf` | the enum's name, and the tag field's name | -| `AnyEnum` | a constraint over any enum | -| `EnumSealer` / `EnumBuilder` | an enum with every step still open | -| `EnumSealed` / `EnumCompanion` | a finished enum companion | +| | | +| ------------------------------------ | ---------------------------------------------------------- | +| `Enum` | a closed set of variants, as one union | +| `Enum.sealer(tag?)` | starts an enum whose variants are callable | +| `Enum.companion(tag?)` | the same, for an enum with a seal of its own | +| `E.match(value, handlers)` | matches on a variant's tag field, exhaustively | +| `E[Tag].match(tag, handlers)` | matches on a tag string, also exhaustively | +| `E(payload)` / `E.seal(payload)` | builds the variant that the payload's tag selects | +| `E[Tag].key` | the tag field's name | +| `E[Variant](payload)` | builds that variant, writing the tag | +| `E[Variant].create(payload)` | the same on a companion, through its seal | +| `E[Variant].patch(value, patch)` | derives a value of that variant, leaving the tag unchanged | +| `.impl(fns?)` | adds members taking the union, and ends the chain | +| `.implVariant(N, sealer => …)` | adds one variant's members, and its seal on a companion | +| `.implSeal(seal)` | replaces the seal shared by every variant | +| `.implTrait(Tr, fns?)` | implements a trait the enum declares | +| `.implTrait(fns)` | the same, for a trait that implements nothing of its own | +| `Tag` | names the tag field, intersected into `X` | +| `VariantOf` | the type of one variant | +| `SeedFor` | what that variant's constructor takes | +| `SealedPayload` | what the enum's seal takes, tag included | +| `VariantsOf` / `SharedOf` | the declared variants, and the shared fields | +| `NameOf` / `TagOf` | the enum's name, and the tag field's name | +| `AnyEnum` | a constraint over any enum | +| `EnumSealer` / `EnumBuilder` | an enum with every step still open | +| `EnumSealed` / `EnumCompanion` | a finished enum companion | ### Trait From c0b3a59277ddfe96486aab11f251fba09b63538a Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:35:02 +0900 Subject: [PATCH 20/24] docs: drop the companion note from the `.implVariant` row --- docs/src/api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/api.md b/docs/src/api.md index b53e50b..2107748 100644 --- a/docs/src/api.md +++ b/docs/src/api.md @@ -72,7 +72,7 @@ re-export a companion. | `E[Variant].create(payload)` | the same on a companion, through its seal | | `E[Variant].patch(value, patch)` | derives a value of that variant, leaving the tag unchanged | | `.impl(fns?)` | adds members taking the union, and ends the chain | -| `.implVariant(N, sealer => …)` | adds one variant's members, and its seal on a companion | +| `.implVariant(N, sealer => …)` | adds one variant's members, and its seal | | `.implSeal(seal)` | replaces the seal shared by every variant | | `.implTrait(Tr, fns?)` | implements a trait the enum declares | | `.implTrait(fns)` | the same, for a trait that implements nothing of its own | From 22b53f06b32bea2329905ea738376cfa4ace64d4 Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:37:06 +0900 Subject: [PATCH 21/24] docs: call the enum companion's seal a custom seal --- docs/src/api.md | 2 +- src/enum.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/src/api.md b/docs/src/api.md index 2107748..499a8c8 100644 --- a/docs/src/api.md +++ b/docs/src/api.md @@ -63,7 +63,7 @@ re-export a companion. | ------------------------------------ | ---------------------------------------------------------- | | `Enum` | a closed set of variants, as one union | | `Enum.sealer(tag?)` | starts an enum whose variants are callable | -| `Enum.companion(tag?)` | the same, for an enum with a seal of its own | +| `Enum.companion(tag?)` | the same, for an enum with a custom seal | | `E.match(value, handlers)` | matches on a variant's tag field, exhaustively | | `E[Tag].match(tag, handlers)` | matches on a tag string, also exhaustively | | `E(payload)` / `E.seal(payload)` | builds the variant that the payload's tag selects | diff --git a/src/enum.ts b/src/enum.ts index 0560a26..91e1d2e 100644 --- a/src/enum.ts +++ b/src/enum.ts @@ -819,7 +819,7 @@ export const Enum = { sealer: (...tag: TagArg): EnumSealer => state(tag[0] ?? "_tag", true, {}, {}, {}, undefined, true) as EnumSealer, /** - * Bundles an enum's members with a seal of its own. Every variant builds with `.create`. + * Bundles an enum's members with a custom seal. Every variant builds with `.create`. */ companion: (...tag: TagArg): EnumBuilder => state(tag[0] ?? "_tag", false, {}, {}, {}, undefined, true) as EnumBuilder, From d21d1bf7da8e975074c7860a1f18d5762cbe9098 Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:38:54 +0900 Subject: [PATCH 22/24] docs: write enum variants as `E.Variant` in the API table --- docs/src/api.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/src/api.md b/docs/src/api.md index 499a8c8..8479694 100644 --- a/docs/src/api.md +++ b/docs/src/api.md @@ -68,9 +68,9 @@ re-export a companion. | `E[Tag].match(tag, handlers)` | matches on a tag string, also exhaustively | | `E(payload)` / `E.seal(payload)` | builds the variant that the payload's tag selects | | `E[Tag].key` | the tag field's name | -| `E[Variant](payload)` | builds that variant, writing the tag | -| `E[Variant].create(payload)` | the same on a companion, through its seal | -| `E[Variant].patch(value, patch)` | derives a value of that variant, leaving the tag unchanged | +| `E.Variant(payload)` | builds that variant, writing the tag | +| `E.Variant.create(payload)` | the same on a companion, through its seal | +| `E.Variant.patch(value, patch)` | derives a value of that variant, leaving the tag unchanged | | `.impl(fns?)` | adds members taking the union, and ends the chain | | `.implVariant(N, sealer => …)` | adds one variant's members, and its seal | | `.implSeal(seal)` | replaces the seal shared by every variant | From e040a5f1cde1c6a84cf7cc203b18ca1714604415 Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:42:57 +0900 Subject: [PATCH 23/24] docs: describe the enum's seal and `Tag` in the reader's terms --- docs/src/api.md | 2 +- src/enum.ts | 13 +++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/src/api.md b/docs/src/api.md index 8479694..18d3115 100644 --- a/docs/src/api.md +++ b/docs/src/api.md @@ -79,7 +79,7 @@ re-export a companion. | `Tag` | names the tag field, intersected into `X` | | `VariantOf` | the type of one variant | | `SeedFor` | what that variant's constructor takes | -| `SealedPayload` | what the enum's seal takes, tag included | +| `SealedPayload` | what an `.implSeal` seal receives, tag included | | `VariantsOf` / `SharedOf` | the declared variants, and the shared fields | | `NameOf` / `TagOf` | the enum's name, and the tag field's name | | `AnyEnum` | a constraint over any enum | diff --git a/src/enum.ts b/src/enum.ts index 91e1d2e..46fd4d3 100644 --- a/src/enum.ts +++ b/src/enum.ts @@ -15,7 +15,8 @@ import { } from "./val.ts"; /** - * The key of what an enum dispatches on a tag alone: `Command[Tag].match(tag, handlers)`. + * The key that holds an enum companion's `match` on a tag string, and its `key`: + * `Command[Tag].match(tag, handlers)`. * * @experimental */ @@ -234,7 +235,7 @@ type Tagged> = SeedFor & }; /** - * What the enum's seal takes: a tagged payload of any one variant. + * What a seal registered with `.implSeal` receives: a tagged payload of any one variant. * * @experimental */ @@ -515,11 +516,11 @@ export type EnumCompanion< */ match: Match; /** - * Builds the variant a tagged payload names, through that variant's constructor. For a payload + * Reads the tag, selects that variant, and passes the payload to its constructor. For a payload * a schema library has already split by the tag. The return is the variants' constructors' * returns, as a union. * - * A payload holding a field of a variant other than the one its tag names is a type error. + * Passing a field of another variant is a type error, even when the tag is a union. */ seal: EnumSeal>; readonly [Tag]: ByTag; @@ -810,8 +811,8 @@ const state = ( /** @experimental */ export const Enum = { /** - * Builds the constructors for an enum: every variant is callable. `Enum.companion` is the same - * shape for an enum with a smart constructor. + * Builds the constructors for an enum: the companion and every variant are callable. + * `Enum.companion` is the same, for an enum with a custom seal. * * The tag name is passed again here because the proxy has to write it at run time, and the type * argument is not readable from a value. From 06c099b61c9e7fc5ec1f210a4d355028eb600155 Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:45:57 +0900 Subject: [PATCH 24/24] docs: shorten two enum rows of the API table --- docs/src/api.md | 52 ++++++++++++++++++++++++------------------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/docs/src/api.md b/docs/src/api.md index 18d3115..bd21cb3 100644 --- a/docs/src/api.md +++ b/docs/src/api.md @@ -59,32 +59,32 @@ re-export a companion. ### Enum -| | | -| ------------------------------------ | ---------------------------------------------------------- | -| `Enum` | a closed set of variants, as one union | -| `Enum.sealer(tag?)` | starts an enum whose variants are callable | -| `Enum.companion(tag?)` | the same, for an enum with a custom seal | -| `E.match(value, handlers)` | matches on a variant's tag field, exhaustively | -| `E[Tag].match(tag, handlers)` | matches on a tag string, also exhaustively | -| `E(payload)` / `E.seal(payload)` | builds the variant that the payload's tag selects | -| `E[Tag].key` | the tag field's name | -| `E.Variant(payload)` | builds that variant, writing the tag | -| `E.Variant.create(payload)` | the same on a companion, through its seal | -| `E.Variant.patch(value, patch)` | derives a value of that variant, leaving the tag unchanged | -| `.impl(fns?)` | adds members taking the union, and ends the chain | -| `.implVariant(N, sealer => …)` | adds one variant's members, and its seal | -| `.implSeal(seal)` | replaces the seal shared by every variant | -| `.implTrait(Tr, fns?)` | implements a trait the enum declares | -| `.implTrait(fns)` | the same, for a trait that implements nothing of its own | -| `Tag` | names the tag field, intersected into `X` | -| `VariantOf` | the type of one variant | -| `SeedFor` | what that variant's constructor takes | -| `SealedPayload` | what an `.implSeal` seal receives, tag included | -| `VariantsOf` / `SharedOf` | the declared variants, and the shared fields | -| `NameOf` / `TagOf` | the enum's name, and the tag field's name | -| `AnyEnum` | a constraint over any enum | -| `EnumSealer` / `EnumBuilder` | an enum with every step still open | -| `EnumSealed` / `EnumCompanion` | a finished enum companion | +| | | +| ------------------------------------ | -------------------------------------------------------- | +| `Enum` | a closed set of variants, as one union | +| `Enum.sealer(tag?)` | starts an enum whose variants are callable | +| `Enum.companion(tag?)` | the same, for an enum with a custom seal | +| `E.match(value, handlers)` | matches on a variant's tag field, exhaustively | +| `E[Tag].match(tag, handlers)` | matches on a tag string, also exhaustively | +| `E(payload)` / `E.seal(payload)` | builds the variant that the payload's tag selects | +| `E[Tag].key` | the tag field's name | +| `E.Variant(payload)` | builds that variant, writing the tag | +| `E.Variant.create(payload)` | the same on a companion, through its seal | +| `E.Variant.patch(value, patch)` | derives a value of that variant, keeping the tag | +| `.impl(fns?)` | adds members taking the union, and ends the chain | +| `.implVariant(N, sealer => …)` | adds one variant's members, and its seal | +| `.implSeal(seal)` | replaces the seal shared by every variant | +| `.implTrait(Tr, fns?)` | implements a trait the enum declares | +| `.implTrait(fns)` | the same, for a trait that has no default implementation | +| `Tag` | names the tag field, intersected into `X` | +| `VariantOf` | the type of one variant | +| `SeedFor` | what that variant's constructor takes | +| `SealedPayload` | what an `.implSeal` seal receives, tag included | +| `VariantsOf` / `SharedOf` | the declared variants, and the shared fields | +| `NameOf` / `TagOf` | the enum's name, and the tag field's name | +| `AnyEnum` | a constraint over any enum | +| `EnumSealer` / `EnumBuilder` | an enum with every step still open | +| `EnumSealed` / `EnumCompanion` | a finished enum companion | ### Trait