diff --git a/docs/src/api.md b/docs/src/api.md index 6eb1961..bd21cb3 100644 --- a/docs/src/api.md +++ b/docs/src/api.md @@ -63,21 +63,23 @@ 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[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 | +| `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 => …)` | builds one variant from its own steps | -| `.implSeal(seal)` | replaces the seal every variant passes | +| `.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 | +| `.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 `E(payload)` 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/docs/src/enums.md b/docs/src/enums.md index 6e7fc9b..587dd08 100644 --- a/docs/src/enums.md +++ b/docs/src/enums.md @@ -35,9 +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. 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. 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. @@ -80,6 +79,44 @@ 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 a value + +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 Command = Enum< + "Command", + { New: { name: string }; Rename: { from: string; to: string }; Check: Record } +>; +const Command = Enum.sealer(); +// 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(input.tag, { + New: () => Command.New({ name: input.args[0] ?? "" }), + Rename: () => Command.Rename({ from: input.args[0] ?? "", to: input.args[1] ?? "" }), + Check: () => Command.Check({}), +}); +``` + +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 +212,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 +221,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 +236,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 diff --git a/notes/design.md b/notes/design.md index 8a9b826..2310772 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` で鎖を閉じる、却下した値の形)、`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,6 +5619,8 @@ Variant の数では増えない。予算は 45,000 に置いた。 #### Variant ごとの seal と union の `seal`、2026-09-16 実装 +**2026-10-14: `seal` の引数を排他的にした。**下の「`seal` の引数を排他的にする」を見る。 + **動機は境界の分岐。**タグ付きのペイロードが来る場所で、利用者は今どの Variant かを自分で見て、対応する コンストラクタを呼んでいる。`Shape.seal(payload)` があれば、タグで枠を引くのはライブラリの仕事になる。 @@ -5708,6 +5710,130 @@ 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` の引数を排他的にする、2026-10-14 + +**タグが 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 ごとに排他的にした。**`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 だけである。 + +**入口は 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` でもう一度書くことになるので、排他的な引数で戻した。その途中で両方の形を `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。 + +#### タグだけで分ける `[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({}), +}); +``` + +- **ハンドラは引数を取らず、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 実装 **レコード 1 個から鎖に変えた。**ネストが 1 段減り、外側の callback が消える。 diff --git a/scripts/type-perf/fixtures/enum.ts b/scripts/type-perf/fixtures/enum.ts index 0aeac2f..f621a80 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,10 +77,11 @@ 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.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 }), +}); 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..46fd4d3 100644 --- a/src/enum.ts +++ b/src/enum.ts @@ -14,6 +14,14 @@ import { type Wired, } from "./val.ts"; +/** + * The key that holds an enum companion's `match` on a tag string, and its `key`: + * `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; @@ -227,8 +235,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 a seal registered with `.implSeal` receives: a tagged payload of any one variant. * * @experimental */ @@ -419,16 +426,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. @@ -442,6 +439,60 @@ 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 = { + /** + * 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, { + * New: () => Command.New({ name: input.args[0] ?? "" }), + * Rename: () => Command.Rename({ from: input.args[0] ?? "", to: input.args[1] ?? "" }), + * 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; + /** + * The name of the tag field, for a schema library that asks for the discriminator: + * `z.discriminatedUnion(Command[Tag].key, [...])`. + */ + 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. * @@ -465,29 +516,36 @@ export type EnumCompanion< */ 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. + * 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. + * + * Passing a field of another variant is a type error, even when the tag is a union. */ - seal: (value: SealedPayload) => Boundary>; + seal: EnumSeal>; + readonly [Tag]: ByTag; /** 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 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. * * @experimental */ -export type EnumSealed, M = Record> = (( - value: SealedPayload, -) => Boundary>) & +export type EnumSealed< + E extends AnyEnum, + VM = Record, + M = Record, +> = EnumSeal> & SealerConstructors & Omit> & { /** See {@link EnumCompanion.match}. */ match: Match; + readonly [Tag]: ByTag; /** Every member `implTrait` registered. Read by `Trait`'s `dyn`. */ readonly __valof_traits: Members; }; @@ -611,11 +669,13 @@ type Sealer = ((payload: Payload) => unknown) & { }; type Companion = { - create: ((payload: Payload) => unknown) & { nocopy: (payload: Payload) => unknown }; + create: (payload: Payload) => unknown; }; type Builds = Record object>; +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 * does not handle itself becomes a frame. @@ -632,6 +692,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 }); @@ -708,8 +769,9 @@ const state = ( 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. + // 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. @@ -749,8 +811,8 @@ const state = ( /** @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: 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. @@ -758,8 +820,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 custom seal. Every variant builds with `.create`. */ companion: (...tag: TagArg): EnumBuilder => state(tag[0] ?? "_tag", false, {}, {}, {}, undefined, true) as EnumBuilder, 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 636164a..d2ac9af 100644 --- a/tests/enum.test.ts +++ b/tests/enum.test.ts @@ -6,8 +6,9 @@ import { type Dyn, type Final, type SeedFor, + type SealedPayload, type Self, - type Tag, + Tag, type VariantOf, } from "../src/experimental.ts"; @@ -91,6 +92,71 @@ 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("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(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, 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({ _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(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({ _tag: "Pin", at: 1 })).toEqual(Mark.Pin({ at: 1 })); + }); +}); + describe("the tag", () => { test("narrows the union", () => { const read = (shape: Shape): number => { @@ -123,6 +189,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"); @@ -490,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 }); @@ -543,37 +624,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 +715,9 @@ describe("the chain closes", () => { }); describe("the companion is a proxy", () => { - test("a sealer's boundary entry is the call, so `seal` resolves to nothing", () => { + 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(); - expect(Enum.companion().impl().seal({ r: 1, _tag: "Circle" })).toEqual({ - r: 1, - _tag: "Circle", - }); }); test("so a symbol and `then` come back undefined", async () => {