From 4671dce7d07fc4d9cf6dad47f1fa2bb7d79184c9 Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi Date: Sun, 20 Sep 2026 01:32:50 +0900 Subject: [PATCH 1/2] feat(enum): implement a trait without passing its companion - share val.ts's `Takes`, `Complete`, `Passes` and `Alone`; drop enum.ts's thinner copies - with them, four gates the enum lacked: the mis-call message, a name another trait answers to, a name a variant's field holds, a companion that skipped a Final - `PayloadKeys` distributes, so an enum answers for every variant rather than the shared fields alone - declarations 58.81 to 58.67 kB --- src/enum.ts | 57 +++++++++++++++---------------- src/val.ts | 16 ++++++--- tests/enum.test.ts | 84 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 122 insertions(+), 35 deletions(-) diff --git a/src/enum.ts b/src/enum.ts index 988d67b..68b1d26 100644 --- a/src/enum.ts +++ b/src/enum.ts @@ -1,21 +1,16 @@ -import type { - AnyTrait, - Implement, - Members, - MembersOf, - NamesOf, - TraitCompanion, - TraitsOf, - Unbound, -} from "./trait.ts"; +import type { AnyTrait, Members, MembersOf, Unbound } from "./trait.ts"; import { define, Val, + type Alone, type AnyVal, type CompanionMembers, + type Complete, type DeepReadonly, type Invalid, + type Passes, type Patch, + type Takes, type Wired, } from "./val.ts"; @@ -430,16 +425,6 @@ type Boundary = { [N in keyof C]: Yields }[keyof C]; type UnionMembers = CompanionMembers & Partial & string) | "match", never>>; -/** Whether the enum declares the trait at all. The check is placed on the parameter (notes §11.1). */ -type Takes = [NamesOf] extends [TraitsOf] - ? Ok - : "the type does not declare this trait"; - -/** The second argument, absent where the trait answered for every member itself. */ -type Passes = [keyof Omit, keyof G>] extends [never] - ? [impl?: Implement] - : [impl: Implement]; - /** Dispatches on the tag. See {@link EnumCompanion.match}. */ type Match = >( value: E, @@ -552,10 +537,17 @@ export type EnumBuilder< * Implements a trait the enum declares. The implementation takes the union, so a member that * differs per variant is a `match` inside it. */ - implTrait: ( - trait: Takes>, - ...impl: Passes - ) => EnumBuilder, E>, F>; + implTrait: { + // No companion to pass when the trait implements nothing of its own: the type argument is + // the whole of it, and the members arrive where the companion would have. + ( + impl: Takes>, + ): EnumBuilder, E>, F>; + ( + trait: Takes>, + ...impl: Passes + ): EnumBuilder, E>, F>; + }; }; /** @@ -585,10 +577,15 @@ export type EnumSealer< ) => R, ) => EnumSealer; /** See {@link EnumBuilder.implTrait}. */ - implTrait: ( - trait: Takes>, - ...impl: Passes - ) => EnumSealer, E>>; + implTrait: { + ( + impl: Takes>, + ): EnumSealer, E>>; + ( + trait: Takes>, + ...impl: Passes + ): EnumSealer, E>>; + }; }; /** Forgotten, misspelled, or passed when the default holds: the type rejects all three. */ @@ -724,10 +721,10 @@ const state = ( // The trait's own members and the enum's, merged the way `val.ts` merges them. return open ? ( - trait: { __valof_shared: Record }, + trait: { __valof_shared?: Record }, fns: Record = {}, ) => { - const grown = { ...traits, ...trait.__valof_shared, ...fns }; + const grown = { ...traits, ...(trait.__valof_shared ?? trait), ...fns }; return step(builds, { ...members, ...grown }, grown, seal, true); } : undefined; diff --git a/src/val.ts b/src/val.ts index c160115..07a6135 100644 --- a/src/val.ts +++ b/src/val.ts @@ -528,7 +528,7 @@ type Grown = M & { * `NamesOf` is `string`: the first branch catches that before the rest read a name that is not * there. */ -type Takes = +export type Takes = string extends NamesOf ? "pass the members this trait leaves open, or name the trait as the type argument" : [NamesOf] extends [TraitsOf] @@ -540,17 +540,17 @@ type Takes = : "the type does not declare this trait"; /** What the companion form takes once the trait itself has answered for every {@link Final}. */ -type Complete = [Exclude, keyof G>] extends [never] +export type Complete = [Exclude, keyof G>] extends [never] ? TraitCompanion : "this trait's companion has not implemented every member declared Final"; /** The second argument, absent where the trait answered for every member itself. */ -type Passes = [keyof Omit, keyof G>] extends [never] +export type Passes = [keyof Omit, keyof G>] extends [never] ? [impl?: Implement] : [impl: Implement]; /** What the companion-less form takes: every member, and only where the trait declares no final. */ -type Alone = [FinalsOf] extends [never] +export type Alone = [FinalsOf] extends [never] ? Implement, V> : "this trait implements members of its own: pass its companion"; @@ -667,8 +667,14 @@ export type CompanionBuilder< /** * The payload's own keys, which a trait member may not shadow: a box forwards everything but a * member to the value, and a frozen field a member shadowed would break the proxy's invariant. + * + * Distributes: `keyof` over an enum's payloads would keep the shared fields alone. */ -type PayloadKeys = Declared extends object ? keyof Declared : never; +type PayloadKeys = V extends unknown + ? Declared extends object + ? keyof Declared + : never + : never; const isObjectShaped = (v: unknown): v is Record => typeof v === "object" && v !== null && !Array.isArray(v); diff --git a/tests/enum.test.ts b/tests/enum.test.ts index 1df45ec..eb02bd8 100644 --- a/tests/enum.test.ts +++ b/tests/enum.test.ts @@ -4,6 +4,7 @@ import { Enum, Trait, type Dyn, + type Final, type SeedFor, type Self, type Tag, @@ -297,6 +298,89 @@ describe("traits", () => { // @ts-expect-error the type does not declare this trait Enum.sealer().implTrait(Describable, { describe: () => "" }); }); + + describe("without a companion", () => { + type Wired = Trait<"Wired", { id: string }, { toWire: (self: Self, sep: string) => string }>; + type Row = Enum<"Row", { Head: { n: number }; Body: { s: string } }, { id: string } & Wired>; + + test("a trait implementing nothing itself is named as the type argument", () => { + const Row = Enum.sealer().implTrait({ + toWire: (r, sep): string => + Row.match(r, { Head: (h) => `${h.id}${sep}${h.n}`, Body: (b) => `${b.id}${sep}${b.s}` }), + }); + expect(Row.toWire(Row.Head({ id: "r1", n: 2 }), "-")).toBe("r1-2"); + }); + + test("a companion's enum takes the same form", () => { + const Row = Enum.companion() + .implTrait({ toWire: (r, sep): string => `${r.id}${sep}${r._tag}` }) + .impl(); + expect(Row.toWire(Row.Head.create({ id: "r1", n: 2 }), "-")).toBe("r1-Head"); + }); + + test("a trait declaring a Final is rejected: only its companion can answer for one", () => { + type Loud = Trait<"Loud", { id: string }, { shout: Final<(self: Self) => string> }>; + type Cry = Enum<"Cry", { A: { n: number }; B: { s: string } }, { id: string } & Loud>; + // @ts-expect-error this trait implements members of its own: pass its companion + Enum.sealer().implTrait({ shout: (c: { id: string }) => c.id }); + }); + + test("a call that names no trait says which of the two it wants", () => { + // `@ts-expect-error` alone would stay green: the call already failed, with TS2558. The + // message is the parameter's type, so passing it fixes the wording. + const unnamed = + "pass the members this trait leaves open, or name the trait as the type argument"; + Enum.sealer().implTrait(unnamed); + // @ts-expect-error the members alone leave `Tr` at its constraint + Enum.sealer().implTrait({ toWire: (r: { id: string }) => r.id }); + }); + }); + + describe("names a member may not take", () => { + test("one a field of any variant holds", () => { + type Rad = Trait<"Rad", { id: string }, { r: (self: Self) => number }>; + type Round = Enum< + "Round", + { Circle: { r: number }; Square: { side: number } }, + { id: string } & Rad + >; + // @ts-expect-error a member cannot take the name of a field the payload holds + Enum.sealer().implTrait({ r: (c: { id: string }) => c.id.length }); + }); + + test("one another trait already answers to", () => { + type Sized = Trait<"Sized", { id: string }, { size: (self: Self) => number }>; + const Sized = Trait.companion().impl({ size: (s) => s.id.length }); + type Wide = Trait<"Wide", { id: string }, { size: (self: Self) => number }>; + const Wide = Trait.companion().impl({ size: (w) => w.id.length * 2 }); + type Both = Enum< + "Both", + { A: { n: number }; B: { s: string } }, + { id: string } & Sized & Wide + >; + // @ts-expect-error another trait already answers to one of these names + Enum.sealer().implTrait(Sized).implTrait(Wide); + // The two entry points carry the gates separately, so each one is read here. + // @ts-expect-error same, on a companion's enum + Enum.companion().implTrait(Sized).implTrait(Wide); + }); + + test("a companion that skipped a Final cannot be implemented", () => { + type Fixed = Trait< + "Fixed", + { id: string }, + { stamp: Final<(self: Self) => string>; note: (self: Self) => string } + >; + type Card = Enum<"Card", { A: { n: number }; B: { s: string } }, { id: string } & Fixed>; + Enum.sealer().implTrait( + // @ts-expect-error this trait's companion has not implemented every member declared Final + Trait.companion(), + // Annotated because the first argument is the error: the implementation loses its + // contextual type along with it. A healthy call needs neither annotation. + { note: (c: VariantOf | VariantOf) => c.id }, + ); + }); + }); }); // Each check goes on the constructor. A broken declaration stops satisfying the constraint, and From a16f9f095e1774ee200893571f979680aebae17a Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi Date: Sun, 20 Sep 2026 01:40:01 +0900 Subject: [PATCH 2/2] docs: document the companion-less implTrait, and mark optional arguments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - api: the type-argument form was missing from both tables - api: `.impl` and `.implTrait` read as taking a required argument; the enum's `.impl(fns?)` was the only row that said otherwise - notes: §15.2 had no record of the asymmetry; it was never a rejected option, just unwritten - notes: sharing val.ts's gates shrank the declarations, against §15.1's +2.1 kB for the same overloads on a Val --- docs/src/api.md | 76 +++++++++++++++++++++++++------------------------ notes/design.md | 38 ++++++++++++++++++++++++- 2 files changed, 76 insertions(+), 38 deletions(-) diff --git a/docs/src/api.md b/docs/src/api.md index 9c1950c..54cbc68 100644 --- a/docs/src/api.md +++ b/docs/src/api.md @@ -2,20 +2,21 @@ ## Values -| | | -| ---------------------------------- | -------------------------------------------- | -| `equals(a, b)` | deeply compares two Vals of the same type | -| `Val.of(value)` | applies the default seal with the type named | -| `Val.of.nocopy(value)` | makes a payload the value without copying | -| `Val.unwrap(value)` | returns a mutable copy of the payload | -| `Val.sealer()` | creates a callable default sealer | -| `Val.sealer().impl(fns)` | adds the type's members, and ends the chain | -| `Val.companion()` | starts a companion without a callable sealer | -| `Val.companion().impl(fns)` | the same, on a companion | -| `.implTrait(Tr, fns)` | implements a trait the type declares | -| `Val.companion().implSeal(f)` | registers a custom seal | -| `Val.companion().implCreate(f)` | registers a function that creates a payload | -| `Val.companion().fixed()` | excludes keys from `patch` | +| | | +| ---------------------------------- | -------------------------------------------------------- | +| `equals(a, b)` | deeply compares two Vals of the same type | +| `Val.of(value)` | applies the default seal with the type named | +| `Val.of.nocopy(value)` | makes a payload the value without copying | +| `Val.unwrap(value)` | returns a mutable copy of the payload | +| `Val.sealer()` | creates a callable default sealer | +| `Val.sealer().impl(fns?)` | adds the type's members, and ends the chain | +| `Val.companion()` | starts a companion without a callable sealer | +| `Val.companion().impl(fns?)` | the same, on a companion | +| `.implTrait(Tr, fns?)` | implements a trait the type declares | +| `.implTrait(fns)` | the same, for a trait that implements nothing of its own | +| `Val.companion().implSeal(f)` | registers a custom seal | +| `Val.companion().implCreate(f)` | registers a function that creates a payload | +| `Val.companion().fixed()` | excludes keys from `patch` | ## Companion members @@ -58,29 +59,30 @@ 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)` | 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 | -| `.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 | -| `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 | -| `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)` | 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 | +| `.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 `E(payload)` 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 diff --git a/notes/design.md b/notes/design.md index ef0c9f8..652683c 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)、型名と一致しない 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 案の却下 + - **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.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 に上げた理由 @@ -5218,6 +5218,42 @@ payload と trait の 2 段になる。得るものが無い。 **`implTrait` もコールバック形が要る。**enum の trait 実装は `match` で書くのが普通で、そこで自分の companion を参照する。§15.4 の範囲に `implTrait` も入る。 +#### companion を渡さない形、2026-09-20 + +**Val にあって Enum に無かった。**§15.1 の `implTrait({…})` が Enum の入口 2 つに届いていない。 +却下した記録は無く、この節にも §9 にも項目が無い。判断ではなく、書かれていなかっただけ。 + +利用者に出るのは TS2558「Expected 2 type arguments, but got 1」で、§15.1 が呼び間違いのために +用意した「pass the members this trait leaves open, or name the trait as the type argument」に +届かない。第 2 引数の contextual typing も一緒に消える。 + +**欠けていた門は 5 つ。**`src/enum.ts` が `Takes` と `Passes` を自前で持ち、val.ts の薄い写しに +なっていた。呼び間違いの文、他の trait が同名に答えていないかの検査、payload のフィールド名との +衝突の検査、`Complete`、`Alone`。持っていたのは「trait を宣言しているか」だけ。 + +**共有は宣言を減らす。**val.ts の `Takes` / `Complete` / `Passes` / `Alone` / `PayloadKeys` は、 +すでに enum.ts の宣言と同じ chunk に出ていた。`export type` にして enum.ts が借りると、重複 +2 本が消えるぶんがオーバーロードの倍化を上回る。宣言は 58.81 → 58.67 kB。§15.1 が Val で ++2.1 kB 払ったのと逆になる。 + +**`PayloadKeys` を分配させた。**`Declared` は `V` に分配するので payload の union になり、 +`keyof` がそれを取ると共通フィールドだけが残る。`PayloadKeys` は `"_tag" | "id"` で、 +Variant 固有の `r` や `side` が落ちていた。trait のメンバが 1 つの Variant のフィールドを隠す形が +通ってしまう。`V extends unknown` で分配させると全 Variant のキーの和になる。Val は union では +ないので変わらない(core の instantiations は 5,733 のまま)。 + +**実行時は `?? trait` の 1 トークン。**val.ts:1103 と同じ。Enum の gzip は残り 106 → 105 B。 + +**`@ts-expect-error` が別のエラーを吸う形を 2 回書いた。**`Alone` のテストのつもりで `Final` を +持たない trait を書き、実際には `Implement` が弾いていた。`Complete` のテストはディレクティブを +呼び出し行に置き、第 2 引数のエラーを吸っていた。`Complete` を外してもエラー自体は出るので、 +ディレクティブは緑のまま残る。val.ts 側(`tests/val.test.ts:1939`)は**第 1 引数の行**に置いて +いる。そこが `Complete` のメッセージを持つ位置。 + +**入口が 2 つなら門も 2 つ。**テストを `Enum.sealer` だけで書いたので、`EnumBuilder` は +`Alone` のオーバーロードを消しても `Takes` を丸ごと外しても緑のままだった。test-audit が +見つけた。検査を足したら、両方の入口で 1 本ずつ落ちることを確かめる。 + #### タグ名は `Tag<…>` を交差して渡す、2026-09-16 **`Enum<"Event", { Click: … }, "kind">` は、`"kind"` が何なのか見て分からない。**位置が意味を持つ引数は、