diff --git a/README.md b/README.md index ae2402f..d2dafbb 100644 --- a/README.md +++ b/README.md @@ -110,14 +110,14 @@ const normalize = (s: string) => s.trim().replace(/\s+/g, " "); const hasText = (s: string) => s.length > 0; const Note = Val.companion() - // create mints the id once. Derivations do not run it again. + // create generates the id once. Derivations do not run it again. .implCreate((text: string) => ({ id: crypto.randomUUID(), text })) // seal validates and normalizes every payload. .implSeal((note, seal): Result => { const text = normalize(note.text); return hasText(text) ? { ok: seal({ ...note, text }) } : { err: "empty note" }; }) - // fixed excludes the minted id from patch. + // fixed excludes the generated id from patch. .fixed<"id">() // impl collects behavior. It infers each member's first parameter as Note. .impl({ diff --git a/docs/src/api.md b/docs/src/api.md index bd21cb3..4d72f9b 100644 --- a/docs/src/api.md +++ b/docs/src/api.md @@ -2,21 +2,22 @@ ## 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 | -| `.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` | +| | | +| ----------------------------------------- | ------------------------------------------------------------ | +| `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().implCreate.noseal(f)` | the same, bypassing the seal for a payload it always accepts | +| `Val.companion().fixed()` | excludes keys from `patch` | ## Companion members diff --git a/docs/src/introduction.md b/docs/src/introduction.md index 08706c0..c777432 100644 --- a/docs/src/introduction.md +++ b/docs/src/introduction.md @@ -46,7 +46,7 @@ It enforces the conventions through types and linting. - **Built-in linter**: Catch convention violations through ESLint, Oxlint or the standalone command. -- **Lightweight**: Starts from 852 B +- **Lightweight**: Starts from 896 B gzipped, with no runtime dependencies. > For background on individual features, see diff --git a/notes/design.md b/notes/design.md index 9d6da80..161fa79 100644 --- a/notes/design.md +++ b/notes/design.md @@ -24,7 +24,7 @@ import { Val } from "valof"; - **§6 スマートコンストラクタと更新** - **6.1** コンストラクタは出自で決まる / **6.2** `patch`、patch の `undefined`、深さを問わない patch、merge できるのは既にあるノードだけ / **6.3** Result 非依存 / **6.4** `update` を削除する - **6.5** `.impl` の第一引数を Val に固定する contextual typing / **6.6** `patch` を上書きさせない - - **6.7** seal(冪等)と create(鋳造)の分離、seal は同期で返す / **6.8** seal を唯一の関門にする。経路ごとのコピー回数 + - **6.7** seal(冪等)と create(生成)の分離、seal は同期で返す / **6.8** seal を唯一の関門にする。経路ごとのコピー回数 - **6.9** 名前が `seal` になるまで / **6.10** `fixed` と余剰プロパティ検査 - **§7 見送ったもの** freeze(dev のみ採用)、Map/Set、Date/Temporal、TaggedEnum、Result、equals のディスパッチ、配線対象を `.impl` に置くこと - **§8 慣用パターン** Record での Set/Map、日付、スキーマライブラリ併用、更新経路から外すフィールド、フレームワークの状態コンテナ(§8.5、dev では再現しない) @@ -574,7 +574,7 @@ Dog.create.nocopy(name); 却下した名前は `.noncopy`(non-copyable と読める)、`.adopt`(避けるコストが見えない)、`unsafeSeal`(検証まで迂回するように読める)。`.nocopy` は変更する機構をそのまま表す。`.nocopy` を勧める lint も足さない。fresh literal 以外の所有権判定には関数や alias をまたぐ escape analysis が要り、readonly 型を根拠にした提案は危険な最適化を正当化する。 -#### 所有権追跡: 自分が鋳造したノードは作り直さない +#### 所有権追跡: 自分が生成したノードは作り直さない `copy` が作ったノードを WeakSet に登録し、次に出会ったらそのまま返す。`src/val.ts` に入っている。 @@ -605,7 +605,7 @@ Dog.create.nocopy(name); #### 却下: 所有権の印を symbol キーで持たせる -WeakSet の代わりに、鋳造したオブジェクト自身に `obj[MARK] = true` を持たせる案。実測(Node 24.19 / M シリーズ、ns/op)。 +WeakSet の代わりに、生成したオブジェクト自身に `obj[MARK] = true` を持たせる案。実測(Node 24.19 / M シリーズ、ns/op)。 | 印を付けるコスト | ns/op | | ------------------------------------------------ | ----- | @@ -1421,7 +1421,7 @@ export const Age = Val.companion() #### 配線対象を `.impl` に書いたら型エラーにする -プリミティブ payload では `V` が第一引数の型に代入可能になることがある(`Val<"Age", number>` に対する `seal(n: number)`)。すると**関数が制約を通り、ただのメソッドとして生えて、`patch` が配線されない**(`create` なら**seal を通らない鋳造経路ができる**)という無言の事故になる。移行中に実際に踏んだ。`seal?: never` / `create?: never` を宣言して弾く。 +プリミティブ payload では `V` が第一引数の型に代入可能になることがある(`Val<"Age", number>` に対する `seal(n: number)`)。すると**関数が制約を通り、ただのメソッドとして生えて、`patch` が配線されない**(`create` なら**seal を通らない生成経路ができる**)という無言の事故になる。移行中に実際に踏んだ。`seal?: never` / `create?: never` を宣言して弾く。 メッセージを型に乗せる案(`seal?: "use .implSeal() …"`)も試したが、`never` を採った。エラー文の見た目は落ちる。optional 修飾子のせいで `never | undefined` になるため、TS は次のように言う。 @@ -1509,24 +1509,24 @@ default patch: {"x":3,"y":2} // 3.7 が seal で切り捨てられている 構造的にこれを弾くことはできない。「引数がペイロードそのもの」を要求すると `seal(input: object)` という zod の正当な形が消える。`object` も `Fields` も同じく `SeedOf` の真の supertype で、区別がつかない。 -#### 規約: seal は冪等(鋳造は `create` へ) +#### 規約: seal は冪等(生成は `create` へ) -seal は「ペイロードに封をする」操作であり、**`seal(v のペイロード)` は `v` を返さなければならない**。ID 生成・時刻・連番のような鋳造はこの法則を破るので `create` に出す。登録も別ステップ。 +seal は「ペイロードに封をする」操作であり、**`seal(v のペイロード)` は `v` を返さなければならない**。ID・時刻・連番の生成はこの法則を破るので `create` に出す。登録も別ステップ。 -| ステップ | 制約 | 生えるもの | -| --------------- | ------------------------------------------------------------------------------- | ---------- | -| `implSeal(f)` | `f: SealImpl`(`(value: SeedOf, ...rest: never[]) => unknown`) | `seal` | -| `implCreate(f)` | `f: Minter`(`(...args: never[]) => SeedOf`。引数は自由、戻りは payload) | `create` | +| ステップ | 制約 | 生えるもの | +| --------------- | ----------------------------------------------------------------------------------- | ---------- | +| `implSeal(f)` | `f: SealImpl`(`(value: SeedOf, ...rest: never[]) => unknown`) | `seal` | +| `implCreate(f)` | `f: CreateImpl`(`(...args: never[]) => SeedOf`。引数は自由、戻りは payload) | `create` | 1. **エラーが間違えた場所で出る。** 多引数コンストラクタは `implSeal` の**登録時**に落ちる。以前は 10 行下の `Point.patch(...)` で怒られていた。「自前の patch を書け」と説明する分岐は不要になり、削除した -2. **`create` と seal が共存できる。** DDD の create / reconstitute の分離そのもの。`create` がペイロードを鋳造し、seal がそれに封をし、`patch` は seal を通るので `create` が再実行されない。§6.6 で「自前の `patch` を書くしかない」と言った ID の例が、既定のまま正しく動く +2. **`create` と seal が共存できる。** DDD の create / reconstitute の分離そのもの。`create` がペイロードを生成し、seal がそれに封をし、`patch` は seal を通るので `create` が再実行されない。§6.6 で「自前の `patch` を書くしかない」と言った ID の例が、既定のまま正しく動く 3. **`create` だけでも `patch` は生える。** 既定の seal(コピー)もペイロード関数だからで、`create` が振った `id` はコピーされて保存される。`Rebuild` は分岐を失った ```ts type Rebuild = (value: V, arg: Arg) => Constructed; ``` -サブセット形(`seal(f: { name: string })`)は今も `SealImpl` の制約を通る。`seal?: never` と同じで、型では閉じない。冪等性も型では書けない。`create` という別ステップを用意し、そこに鋳造の置き場を作ることで、**規約を守る側が楽になる**という形で担保する。 +サブセット形(`seal(f: { name: string })`)は今も `SealImpl` の制約を通る。`seal?: never` と同じで、型では閉じない。冪等性も型では書けない。`create` という別ステップを用意し、そこに生成の置き場を作ることで、**規約を守る側が楽になる**という形で担保する。 **名前は `create`。** `new` はプロパティ名としては合法(`User.new(...)` は動く)だが、型リテラルで `{ new(x: number): Y }` と書くと構築シグネチャとして解析される罠があり、予約語でもある。 @@ -1570,7 +1570,7 @@ seal の戻り値は `unknown` だったので、async の seal が型を通っ **戻り型が `any` の seal は通す。**`any` は `PromiseLike` にも合うので、除外しないと同期の seal に 「a seal is synchronous」という嘘のエラーが出る。 -`create` は元から async にできない。minter の戻り型が `SeedOf` で、`Promise` はそこに代入できない。 +`create` は元から async にできない。生成関数の戻り型が `SeedOf` で、`Promise` はそこに代入できない。 例外は空の payload(`Record`)で、`Promise` が代入できるので通る。塞いでいない。 **却下: development で、カスタム seal が return した後に既定の seal が呼ばれたら投げる。**型を通る @@ -1592,9 +1592,9 @@ Task.create({ title: "x" }); // id が振られる Task({ id: "forged", … }); // ← 隣で素通り ``` -companion の `create` は「鋳造は `create`、封は seal、それ以外に値になる経路は無い」(§6.7 / §6.8)の一部として意味を持つ。sealer に同じ名前を置くと、同じ `create` が型によって強さの違うものになり、守っているように読めて何も守らない。`.fixed`(§6.10)も companion builder のステップなので、道具立てとしても中途半端になる。 +companion の `create` は「生成は `create`、封は seal、それ以外に値になる経路は無い」(§6.7 / §6.8)の一部として意味を持つ。sealer に同じ名前を置くと、同じ `create` が型によって強さの違うものになり、守っているように読めて何も守らない。`.fixed`(§6.10)も companion builder のステップなので、道具立てとしても中途半端になる。 -多引数の別名コンストラクタ(`Point.create(x, y)`)が欲しいだけなら素の export 関数で足りる。そして **「sealer に `create` が欲しい」はたいてい companion が欲しい合図**で、鋳造したいフィールドがあるなら素の payload を誰でも渡せてはいけない。無いことがその方向に押す。 +多引数の別名コンストラクタ(`Point.create(x, y)`)が欲しいだけなら素の export 関数で足りる。そして **「sealer に `create` が欲しい」はたいてい companion が欲しい合図**で、生成したいフィールドがあるなら素の payload を誰でも渡せてはいけない。無いことがその方向に押す。 後方互換に追加できるステップなので、実例が積み上がったら再考すればよい。 @@ -1608,7 +1608,7 @@ companion の `create` は「鋳造は `create`、封は seal、それ以外に User.create(fields); // → Result ``` -- **値になる経路が 1 本になる。** 「payload が Val になるのは seal を通るときだけ」がライブラリの不変条件になり、`create` は「引数から payload を組む」だけの存在に痩せる +- **値になる経路が 1 本になる。** 「payload が Val になるのは seal を通るときだけ」がライブラリの不変条件になり、`create` は「引数から payload を組む」だけの存在に痩せる。例外は `.implCreate.noseal` で、production では seal を飛ばす(§9) - **戻り型が揃う。** 以前は `create` が `User`、`from` が `Result` を返す不揃いがありえた。今はどちらも `Constructed` - **ユーザーコードから `Val.of` が 1 つ減る** - **カスタム seal がないときは既定の seal(コピー)に落ちる。** `implCreate` の制約を `F` の有無で変えると登録順で `create` の型が変わるので、常に `SeedOf` を要求して実行時にどちらの seal を使うかを決める @@ -1644,6 +1644,7 @@ seal の引数を可変(`PayloadOf`)にして、公開 `seal` が入口 | `V.patch(v, patch)`(カスタム seal あり) | 通る | 1 + seal の導出分 | 1 | | `V.create(args)` | 通る | create が組む分(通常 1) | 1 | | `V.create.nocopy(args)` | 通る | create が組む分(通常 1) | **0**(末端の payload を採用) | +| `V.create()`(`.implCreate.noseal`) | production では通らない | create が組む分(通常 1) | 1(`.nocopy` なら 0) | | `.impl` の自前 `patch` → 第 3 引数の seal | 通る | 自前実装しだい | 1 | | `.impl` の自前 `patch` → `Val.of` | **通らない** | 同上 | 1 | | `Val.unwrap(v)` → 加工 → `V.seal(...)` | 通る | 加工分 | **2**(unwrap と seal で 1 回ずつ) | @@ -1692,7 +1693,7 @@ custom seal の中で値を作るには `Val.of(n)` と型引数を書く - **`from`**: `implFrom` が Rust の `impl From for U`(=任意の型からの変換)に読める。それは今の `create` の意味であり、**名前が指すものが実際と逆**だった - **`validate`**: 検査は seal がやることの一つにすぎない。正規化(§5 の Email)も `Result` 包みも「検証」ではないし、`validate(x)` が引数をコピーするのは奇妙。boolean を返す述語だと誤読させる語でもある - **`parse`**: コピーも失敗も自然に読める。しかし parse は「緩い入力を解いて型にする」語なので、**引数がペイロードに固定されている理由を名前が説明しない**。その制約は `patch` がここを通せるために本質的なもので、恣意的に見えては困る -- **`seal`**: 封をする対象は中身そのものなので、引数がペイロードであることが名前から出る。すでにライブラリの語彙(`Val.sealer`)で、三分割が一列に並ぶ。「鋳造してはいけない」という §6.7 の規約も動詞から読み取れる +- **`seal`**: 封をする対象は中身そのものなので、引数がペイロードであることが名前から出る。すでにライブラリの語彙(`Val.sealer`)で、三分割が一列に並ぶ。「生成してはいけない」という §6.7 の規約も動詞から読み取れる | | 引数 | 制約 | | ----------------- | ------- | ---------------------------- | @@ -1704,7 +1705,7 @@ custom seal の中で値を作るには `Val.of(n)` と型引数を書く ### 6.10 `fixed`: 派生経路から外すキー -`create` が鋳造した `id` を派生経路から触らせない、という §8.4 のパターンは、**`patch` を上書きする**ことでしか書けなかった。上書きの中身は既定と 1 文字も違わず、**唯一の内容は patch の型**である。§6.5 / §6.6 で「ラバースタンプを書かせない」と決めておきながら、最も頻出するパターンでそれを強制していた。 +`create` が生成した `id` を派生経路から触らせない、という §8.4 のパターンは、**`patch` を上書きする**ことでしか書けなかった。上書きの中身は既定と 1 文字も違わず、**唯一の内容は patch の型**である。§6.5 / §6.6 で「ラバースタンプを書かせない」と決めておきながら、最も頻出するパターンでそれを強制していた。 宣言のステップを足す。 @@ -1727,7 +1728,7 @@ Val.companion() - `impl` 接頭辞は付けない。`impl*` は「X の実装を与える」に揃えたが、これは関数を 1 つも渡さず型について宣言するだけのステップ。**実装を与える手順と、性質を宣言する手順が名前で区別される**ほうが良い - `final` は JVM の語彙(`final val` / `sealed abstract class`)を持ち込み、`seal` を `sealed trait` の意味に誤読させる。`finalize` は GC のファイナライザ(`FinalizationRegistry`)と、ビルダーの終端(`.build()`)の両方に読める -- `minted` は §6.7 の鋳造の比喩と最も整合するが、`create` を持たない型では意味が曖昧になり、英語圏外の読み手には通じにくい +- `minted`(鋳造)は `create` の役割と最も整合するが、`create` を持たない型では意味が曖昧になり、英語圏外の読み手には通じにくい - `unpatchable` は既に公開している `Patch` からしか意味を取らない。新しい比喩も、やっていること以上の約束も持ち込まない #### `unpatchable` → `fixed` @@ -1930,7 +1931,7 @@ export const IsoDate = Val.companion() ### 8.3 スキーマライブラリとの併用 -検証を seal に置けば、更新経路(`patch`)も鋳造経路(`create`)も全部そこを通る(§6.2 / §6.8)。引数は `object` まで広げてよい。`SealImpl` はペイロードを**受け取れる**ことしか要求しない。`unknown` は `string` も受けるので落ちる(§6.7)。 +検証を seal に置けば、更新経路(`patch`)も生成経路(`create`)も全部そこを通る(§6.2 / §6.8)。引数は `object` まで広げてよい。`SealImpl` はペイロードを**受け取れる**ことしか要求しない。`unknown` は `string` も受けるので落ちる(§6.7)。 ```ts export const User = Val.companion() @@ -1959,7 +1960,7 @@ export function parseUser(json: string): Result { ### 8.4 更新経路から外したいフィールド -コンストラクタ内で生成する ID、`createdAt`、バージョン番号。`create` が鋳造し、seal が封をし、`.fixed`(§6.10)が更新経路から外す。 +コンストラクタ内で生成する ID、`createdAt`、バージョン番号。`create` が生成し、seal が封をし、`.fixed`(§6.10)が更新経路から外す。 ```ts type Fields = Omit, "id">; @@ -2012,6 +2013,91 @@ payload 全体を作り直す経路(コンストラクタ、`seal`)は塞が ## 9. 未解決 / 要確認 +- [x] ~~**seal を通さない `create`。**~~ → 入れた。`.implCreate.noseal`、2026-10-14。必ず seal を通る + payload を生成する `create` が、seal の戻り型をそのまま返してしまう。定義側の段で外す + + ```ts + const SectionId = Val.companion() + .implCreate(() => randomBytes(4).toString("hex")) + .implSeal((text, seal): SectionId | undefined => + /^[0-9a-f]{8}$/.test(text) ? seal(text) : undefined, + ); + + const id = SectionId.create(); // SectionId | undefined。undefined には到達しない + if (id === undefined || used.has(id)) return mintIds(used, count); // 衝突の再試行に畳まれた + ``` + + - **類型である。**形式を検査する seal を持つ ID 型が自分で ID を生成すると、必ずこの形になる(uuid、 + nanoid)。到達しない分岐が再試行に畳まれると、生成関数が壊れたときにエラーにならず再帰が止まらない + - **形。**`.implCreate.noseal(fn)`。公開される `create` と `create.nocopy` は `V` を返す。`patch` と + `seal` は変えない。後から普通の `implCreate` を書けば seal を通る形に戻る + - **既定は seal を通すまま。**`noseal` の付け忘れは戻り型に `| undefined` が出るので、コンパイル時に + 呼び出し側で気づける。逆を既定にすると、seal の付け忘れが型を通り、production で無検査の値を作る。 + `.nocopy` を opt-in にした基準(§4.1)と同じ + - **`noseal` の生成関数は引数を取れない。**分かれ目は生成関数が未検証の入力を受け取るかどうかで、 + 引数を禁じればその経路が型で閉じる。入力空間が無いので、development の検査は 1 回走ればほぼ網羅的に + なる。制約を `() => SeedOf` にするだけでは省略可能な引数と rest 引数が通るので、 + `Parameters extends []` を別に見て `Invalid<…>` で落とす。引数を許すのは後方互換な追加なので、 + 実例が出てから緩める + - **payload の型を間違えたときは、戻り値の位置でエラーになる。**`G` の制約は引数の形だけにして、 + 戻り型が合わなければ引数の型を `() => SeedOf` にする。`G & (() => SeedOf)` にすると、戻り型が + 一致したときに TypeScript が交差の片側と打ち消して `G` を推論しない(5.9 / 6.0 / 7.0 で確認) + - **引数を取って必ず成功する生成関数は `implAssoc` と `Val.of` で書く。**検証済みの Val だけを受け取る + 形(`Session.create(user)`)がこれに当たる。`implAssoc` は Val を受け取らないメンバーを置く段で、 + 未実装、このメモにも未記載。この経路に development の検査は付かない。`bypassed-companion`(§14.23) + は変えず、この `Val.of` にも警告する。書けるが勧める形ではないので、disable コメントが妥当 + - **クロージャ越しの入力は型で塞がない。**Val の定義が関数の中にある時点で気づける。検査するなら + 「ファイルのトップレベル以外での Val 定義を禁じる」lint 規則。モジュールレベルの可変な状態を読む + 生成関数はこの規則でも残る。そこを縛ると TypeScript の意味論を超えるので縛らない + - **production は既定の seal だけを通す。**カスタム seal は呼ばない + - **development はカスタム seal を実際に走らせる。**`custom(payload, terminal)` で、terminal が + 呼ばれなければ拒否、呼ばれた候補が payload と等しくなければ正規化と判定して投げる。返すのは terminal + の結果で、seal の戻り値は見ないので §6.3 は破らない。`changedDuringSeal` と同じ仕組み + - **契約は「`create` の出力は seal の不動点」。**検査を通るだけでは足りない。正規化もする seal + (`Math.round`)を飛ばすと正規形でない値ができ、`equals` と、何も変えない `patch` の同一性 + (`keep` は seal を通す)が壊れる + - **lint は `.implCreate.noseal(f)` を `implCreate` の段として読む。**`stepOf`(`scan/chains.ts`)。 + 受け手が呼び出しでなくなるので、読まないと鎖がそこで切れ、`bypassed-companion` が companion を + 見失い、後ろの `implTrait` を `unimplemented-trait` が見落とす + - **コスト。**production の gzip が +44 B(852 → 896 B)、`index.d.mts` が +1.2 kB。development の + 検査は production のビルドで畳まれて消える。増えたのは配線(`implCreate` に生やした `noseal`、 + フラグ、seal を通すかの分岐)。インスタンス化の数はほぼ変わらない + - **却下: 呼び出し側の `create.noseal()`。**`.nocopy` の形は合わない。所有権は呼び出しごとの事実だが、 + 「出力が必ず seal を通る」は生成関数の実装の性質で、定義側で 1 回言えば足りる。呼び出し側に置くと + `create` を持つ companion すべてで「seal が唯一の関門」(§6.8)が利用者ごとに外せるようになる。動機も + 違う。seal を飛ばしても末端のコピーは残るので、買えるのは速度ではなく戻り型 + - **却下: 素の export 関数で 1 回 unwrap する。**`Result` なら 1 行で済むが、`V | undefined` を返す + seal には unwrap が無い。`!` は実行時に何も検査しないので、throw を手で書くことになる + - **却下: 生成関数が `V` を返したら seal を飛ばす。**ブランドがファントムなので実行時に区別できない + - **却下: `Val.of(…)` を生成関数の外で使う。**`bypassed-companion`(§14.23)が警告し、development + の検査も付かない + - **`implAssoc` と `Val.of` があっても段を足す。**同じものは書けるが、development の検査と `create` + という名前が残る。どちらもバンドルの微増に見合う + - **async の seal は先に禁じた**(§6.7「seal は同期で返す」)。development の検査は同期を前提に書ける + +- [ ] **noseal の development の検査が見逃す seal。lint 規則の案、未着手、2026-10-14。**既定の seal を + 呼んだあと、その結果を捨てて失敗を返す seal は、「呼ばれた、値も等しい」ので検査を通る。production + では seal を飛ばすので、拒むはずの値ができる。`Result` に限らず `undefined` を返す seal でも同じ + + ```ts + .implCreate.noseal(() => randomId()) + .implSeal((text, seal) => { + const id = seal(text); + return isReserved(id) ? undefined : id; // 拒否を development の検査が見ない + }) + ``` + + - **戻り値は見ない。**中に既定の seal の結果があるかを development で探せば防げる。production の + バンドルには載らないが、§6.3 に反する。決め手は primitive の payload で、同一性で探せず、`Err` の + メッセージに同じ文字列があると取り違える。文字列の ID がこの段の主な用途なので効く + - **lint 規則にする。**`.implCreate.noseal` を持つ鎖で、`implSeal` に渡したインラインの関数を読む。第 2 + 引数の呼び出しが、`return` する式(アロー関数なら本体の式)の中になければ警告する + - **noseal の鎖だけに絞る。**seal してから捨てて失敗を返すのは、普通の seal では正当な書き方 + - **変数に置いたら一律に警告する。**値が `return` まで流れたかを追うにはデータフローが要る。 + `const v = seal(x); return ok(v)` も警告されるが、`return ok(seal(x))` に書き直せる + - **見えない形。**`.implSeal(hex)` のように別の場所で定義した関数は追わず、何も言わない + - lint は今、`implSeal` の関数の中身を読まない(`CompanionSite.seals` は真偽値だけ)。別の PR で入れる + - [ ] **development の `.nocopy` が失敗した時の部分 freeze。** graph の走査中に後続 node の accessor や cycle で失敗すると、それより先に検査した子だけが owned に登録され freeze されたまま残る。失敗時の副作用を消すなら、全 graph の検査後に登録と freeze を行う二段階処理へ変える - [x] ~~トップレベルの payload に `null` を許すか~~ → 許さない。`null & { __brand: K }` は `never` なので、値を `null` のままブランドで区別できない。union にブランドを置けば素の `null` も @@ -2150,7 +2236,7 @@ payload 全体を作り直す経路(コンストラクタ、`seal`)は塞が - デフォルト `equals` とオーバーライド - コンストラクタの有無を出自で決める(sealer 経由か否か) - コンストラクタによる引数のディープコピーと、明示的な `.nocopy`(§4.1) -- コンストラクタの登録を 2 系統に分ける(`implSeal` = 封をする唯一の関門 / `implCreate` = payload を鋳造して seal に流す、§6.7 / §6.8) +- コンストラクタの登録を 2 系統に分ける(`implSeal` = 封をする唯一の関門 / `implCreate` = payload を生成して seal に流す、§6.7 / §6.8) - `.impl` のメソッドは第一引数が Val に固定され、注釈不要(§6.5) - `patch`(Result 非依存、seal を通る。上書きはさせない、§6.6) - `.fixed()`(更新経路から外すキーを型引数で宣言、§6.10) diff --git a/src/enum.ts b/src/enum.ts index 0a3baf7..ddea18a 100644 --- a/src/enum.ts +++ b/src/enum.ts @@ -282,8 +282,8 @@ type Built, FE, FV> = [FV] exte : Returned; /** A constructor and the same constructor without the copy. See `Val`'s `create`. */ -type Mints = ((payload: A) => R) & { - /** Reuses the seal without copying its terminal payload. */ +type Creates = ((payload: A) => R) & { + /** The same, without copying the payload. */ nocopy: (payload: A) => R; }; @@ -334,14 +334,14 @@ type VariantFrame, FE, FV, T> = >; type Framed, R, Sealed, T> = Omit & { - /** Mints the payload with its tag and seals it, so it returns whatever the seal returns. */ - create: Mints, R>; + /** Creates the payload with its tag and seals it, so it returns whatever the seal returns. */ + create: Creates, R>; /** Derives through the same seal the constructor uses. See {@link Patch}. */ patch: (value: VariantOf, patch: Patch>) => R; } & (Sealed extends true ? { /** The single gate a payload of this variant passes. `create` and `patch` go through it. */ - seal: Mints, R>; + seal: Creates, R>; } : Record); @@ -699,12 +699,12 @@ const state = ( const byTag = { match: matchTag, key: tag }; const built = (name: string, custom: Seal | undefined, fns: Payload): object => { - const mint = (payload: Payload) => ({ ...payload, [tag]: name }); + const tagged = (payload: Payload) => ({ ...payload, [tag]: name }); if (callable) { // The default seal, plus the `patch` that derives through it. The tag is not in the patch's // type and the value carries it already, so the merge leaves it alone. const sealer = Val.sealer() as unknown as Sealer; - const frame = (payload: Payload) => sealer(mint(payload)); + const frame = (payload: Payload) => sealer(tagged(payload)); define(frame, "patch", sealer.patch); for (const key of Object.keys(fns)) define(frame, key, fns[key]); return frame; @@ -722,7 +722,7 @@ const state = ( impl: (fns: object) => object; }; } - ).implCreate(mint); + ).implCreate(tagged); return composed ? chain.implSeal(composed).impl(fns) : chain.impl(fns); }; diff --git a/src/lint/scan/chains.ts b/src/lint/scan/chains.ts index 26b9b08..b8e50c9 100644 --- a/src/lint/scan/chains.ts +++ b/src/lint/scan/chains.ts @@ -9,7 +9,7 @@ import { } from "../ast.ts"; import type { Enum } from "../../enum.ts"; import type { Trait } from "../../trait.ts"; -import type { Val } from "../../val.ts"; +import type { AnyVal, CompanionBuilder, Val } from "../../val.ts"; import { original, symbolRef, type Bindings, type Root, type SymbolRef } from "./bindings.ts"; /** `K`, which must be a key of every member of `T`. */ @@ -54,6 +54,24 @@ export type CompanionSite = Where & { seals: boolean; }; +type Called = { name: string | undefined; property: Node; receiver: Node }; + +/** Reads `.implCreate.noseal(f)` as the `implCreate` step. */ +export function stepOf(callee: Node): Called | undefined { + const receiver = child(callee, "object"); + const property = child(callee, "property"); + if (!receiver || !property) return undefined; + const name = keyName(property, callee["computed"] === true); + if ( + name === ("noseal" satisfies keyof CompanionBuilder["implCreate"]) && + receiver.type === "MemberExpression" + ) { + const outer = stepOf(receiver); + if (outer?.name === "implCreate") return outer; + } + return { name, property, receiver }; +} + /** Which entry point a chain grows from, however the name was bound. */ export function fromRoot(node: Node, bound: Bindings): Root | undefined { const path = rootPath(node); @@ -178,17 +196,15 @@ function readChain(node: Node, bound: Bindings): Chain { if (current.type !== "CallExpression") return undefined; const callee = child(current, "callee"); if (!callee || callee.type !== "MemberExpression") return undefined; - const receiver = child(callee, "object"); - if (!receiver) return undefined; + const step = stepOf(callee); + if (!step) return undefined; + const { name, receiver } = step; if (receiver.type !== "CallExpression") { of = fromRoot(callee, bound); if (!of) return undefined; - const step = rootPath(callee)?.at(-1); - root = step === "companion" ? "companion" : "sealer"; + root = rootPath(callee)?.at(-1) === "companion" ? "companion" : "sealer"; return child(current, "typeArguments"); } - const property = child(callee, "property"); - const name = property && keyName(property, callee["computed"] === true); const [argument] = children(current, "arguments"); if (name && argument) steps.add(name); return walk(receiver); diff --git a/src/lint/scan/index.ts b/src/lint/scan/index.ts index 6f4a626..5a031d4 100644 --- a/src/lint/scan/index.ts +++ b/src/lint/scan/index.ts @@ -35,6 +35,7 @@ import { import { companionSite, fromRoot, + stepOf, typeReference, valOf, type CompanionSite, @@ -525,7 +526,7 @@ function implTrait( while (current?.type === "CallExpression") { const inner = child(current, "callee"); if (!inner || inner.type !== "MemberExpression") break; - const receiver = child(inner, "object"); + const receiver = stepOf(inner)?.receiver; if (!receiver || receiver.type !== "CallExpression") break; current = receiver; } @@ -737,10 +738,8 @@ function detachedStep( ): DetachedStep | undefined { const callee = child(node, "callee"); if (!callee || callee.type !== "MemberExpression") return undefined; - const property = child(callee, "property"); - const step = property && keyName(property, callee["computed"] === true); - if (!step || !STEPS.has(step)) return undefined; - const receiver = child(callee, "object"); + const { name: step, property, receiver } = stepOf(callee) ?? {}; + if (!step || !property || !STEPS.has(step)) return undefined; if (!receiver || receiver.type === "CallExpression") return undefined; if (fromRoot(callee, bound)) return undefined; const where = at(property["start"] as number); diff --git a/src/val.ts b/src/val.ts index aad3b3e..03ddbcc 100644 --- a/src/val.ts +++ b/src/val.ts @@ -425,20 +425,39 @@ export type Synchronous unknown, Checked> = * What `create` must be: any arguments, a payload out. The result is not a value yet. The type's * seal closes it, so `create` cannot bypass the seal. */ -type Minter = (...args: never[]) => SeedOf; +type CreateImpl = (...args: never[]) => SeedOf; + +/** Marks a `create` registered by `.implCreate.noseal`. */ +type Unsealed = { readonly __valof_noseal: true }; + +/** + * No arguments, so no input reaches the payload unsealed. A wrong payload is checked against + * `() => SeedOf`, so the error points at the returned value. `G` stays bare, because an + * intersection stops TypeScript inferring it. + */ +type Argumentless = [Parameters] extends [[]] + ? [ReturnType] extends [SeedOf] + ? G + : () => SeedOf + : Invalid<"`.implCreate.noseal` takes a function with no arguments">; /** The public face of `create`: its own arguments, and whatever the seal returns. */ -type Minting = N extends (...args: infer A) => unknown - ? (...args: A) => Constructed - : undefined; +type Creating = [N] extends [Unsealed] + ? () => V + : N extends (...args: infer A) => unknown + ? (...args: A) => Constructed + : undefined; -type CreateMethod = [Minting] extends [undefined] +type CreateMethod = [Creating] extends [undefined] ? Record : { - /** Mints a payload and seals it, so it returns whatever the seal returns. */ - create: Minting & { - /** Reuses the minter and seal without copying their terminal payload. */ - nocopy: Minting; + /** + * Creates a payload and seals it, so it returns whatever the seal returns. With + * `.implCreate.noseal`, returns the Val. + */ + create: Creating & { + /** The same, without copying the payload. */ + nocopy: Creating; }; }; @@ -622,8 +641,8 @@ export type Sealer = Record ): CompanionBuilder, V>>; }; - /** Registers the payload-minting constructor as `create`. Any arguments, a payload out. */ - implCreate: >(create: G) => CompanionBuilder; + /** Registers a function that creates a payload, as `create`. Any arguments, a payload out. */ + implCreate: (>(create: G) => CompanionBuilder) & { + /** + * A `create` that bypasses the seal and returns the Val, for a payload the seal always + * accepts, such as a random id. Takes no arguments. Development still runs the seal, and + * throws if it rejects the payload or if normalization changes it. + */ + noseal: (create: Argumentless) => CompanionBuilder; + }; /** * Replaces the seal. Its parameter may be wider than the payload, so a schema library can parse * into it, but not so wide that a wire format fits: see {@link CheckedSeal}. @@ -671,7 +697,7 @@ export type CompanionBuilder< /** * Takes keys out of the derivation path: `patch` stops accepting them in its patch. * - * For what a `create` mints and nothing afterwards may change: an id, a `createdAt`, a + * For what a `create` generates and nothing afterwards may change: an id, a `createdAt`, a * version counter. * * ```ts @@ -838,6 +864,10 @@ const changedDuringSeal = (): never => { throw new TypeError("The payload changed while the custom seal was running."); }; +const notFixedPoint = (): never => { + throw new TypeError("The seal rejected or changed a payload from `.implCreate.noseal`."); +}; + /** * Builds the recursive deep copy, in an owning and a non-owning form. * @@ -974,6 +1004,7 @@ export const define = (target: T, key: string, value: unknown) /** What the builder's steps registered. `.fixed` is a type argument alone, so it leaves none. */ type Ctors = { create?: AnyFn; + noseal?: boolean; seal?: (value: unknown, seal: (value: unknown) => unknown) => unknown; }; @@ -1031,7 +1062,7 @@ const attach = ( traits: Record, callable: boolean, ): Record => { - const { create, seal: custom } = ctors; + const { create, noseal, seal: custom } = ctors; const sealedWith = (terminal: (value: unknown) => unknown): ((value: unknown) => unknown) => custom ? development @@ -1056,15 +1087,29 @@ const attach = ( : terminal; const seal = sealedWith(own); const nocopy = sealedWith(adopt); + // Development runs the seal anyway, and throws unless it calls the default seal with an + // equal payload. + const unsealed = (terminal: (value: unknown) => unknown): ((value: unknown) => unknown) => + custom && development + ? (value) => { + const result: { sealed?: unknown } = {}; + sealedWith((candidate) => { + if (!equalsUnknown(value, candidate)) notFixedPoint(); + return (result.sealed = terminal(candidate)); + })(value); + return "sealed" in result ? result.sealed : notFixedPoint(); + } + : terminal; // A derivation that changed nothing returns the value it started from, so a framework comparing // by identity sees no update. A custom seal owns the return shape, so the value goes back // through it: the copy inside recognizes the node and returns the same one. const keep: (value: unknown) => unknown = custom ? seal : (value) => value; if (create) { - const minted = (...args: never[]) => seal(create(...args)); - minted.nocopy = (...args: never[]) => nocopy(create(...args)); - target.create = minted; + const [copying, adopting] = noseal ? [unsealed(own), unsealed(adopt)] : [seal, nocopy]; + const created = (...args: never[]) => copying(create(...args)); + created.nocopy = (...args: never[]) => adopting(create(...args)); + target.create = created; } if (custom) { define(seal, "nocopy", nocopy); @@ -1134,7 +1179,9 @@ const build = ( }); if (callable) return target; - target.implCreate = (create: AnyFn) => step({ ...ctors, create }); + target.implCreate = Object.assign((create: AnyFn) => step({ ...ctors, create, noseal: false }), { + noseal: (create: AnyFn) => step({ ...ctors, create, noseal: true }), + }); target.implSeal = (seal: NonNullable) => step({ ...ctors, seal }); target.fixed = () => step(ctors); return target; diff --git a/tests/enum.test.ts b/tests/enum.test.ts index d9de3c8..974e370 100644 --- a/tests/enum.test.ts +++ b/tests/enum.test.ts @@ -42,11 +42,11 @@ describe("the declaration", () => { const Crate = Enum.companion() .implSeal((c, seal) => seal(c)) .impl(); - const [minted, checked, copied] = [["a"], ["a"], ["a"]]; - Crate.Full.create.nocopy({ items: minted }); + const [created, checked, copied] = [["a"], ["a"], ["a"]]; + Crate.Full.create.nocopy({ items: created }); Crate.Full.seal.nocopy({ items: checked, _tag: "Full" }); Crate.Full.create({ items: copied }); - expect([minted, checked, copied].map(Object.isFrozen)).toEqual([true, true, false]); + expect([created, checked, copied].map(Object.isFrozen)).toEqual([true, true, false]); }); test("the payload is deep-copied, like any other Val", () => { diff --git a/tests/lint/rules/bypassed-companion/fixtures/noseal.ts b/tests/lint/rules/bypassed-companion/fixtures/noseal.ts new file mode 100644 index 0000000..09a1a8e --- /dev/null +++ b/tests/lint/rules/bypassed-companion/fixtures/noseal.ts @@ -0,0 +1,9 @@ +import { Val } from "valof"; + +type Age = Val<"Age", number>; + +const Age = Val.companion() + .implCreate.noseal(() => 0) + .implSeal((n, seal) => seal(Math.trunc(n))); + +export const zero = (): Age => Val.of(0); diff --git a/tests/lint/rules/bypassed-companion/index.test.ts b/tests/lint/rules/bypassed-companion/index.test.ts index 2f4a98a..8a6e948 100644 --- a/tests/lint/rules/bypassed-companion/index.test.ts +++ b/tests/lint/rules/bypassed-companion/index.test.ts @@ -28,6 +28,12 @@ test("names the seal that the lift skips", async () => { ]); }); +test("reads the chain through `.implCreate.noseal`", async () => { + expect(await messages("noseal")).toEqual([ + "Val.of bypasses Age.seal, which checks the payload", + ]); +}); + test("says what is missing where the companion has no seal to name", async () => { expect(await lint("no-seal")).toEqual([ { diff --git a/tests/lint/rules/detached-impl/fixtures/noseal/consumer.ts b/tests/lint/rules/detached-impl/fixtures/noseal/consumer.ts new file mode 100644 index 0000000..18627ad --- /dev/null +++ b/tests/lint/rules/detached-impl/fixtures/noseal/consumer.ts @@ -0,0 +1,3 @@ +import { Age } from "./model.ts"; + +export const Created = Age.implCreate.noseal(() => 0); diff --git a/tests/lint/rules/detached-impl/fixtures/noseal/model.ts b/tests/lint/rules/detached-impl/fixtures/noseal/model.ts new file mode 100644 index 0000000..bef31a5 --- /dev/null +++ b/tests/lint/rules/detached-impl/fixtures/noseal/model.ts @@ -0,0 +1,4 @@ +import { Val } from "valof"; + +export type Age = Val<"Age", number>; +export const Age = Val.companion().implSeal((n, seal) => seal(Math.trunc(n))); diff --git a/tests/lint/rules/detached-impl/index.test.ts b/tests/lint/rules/detached-impl/index.test.ts index f952616..4b82a62 100644 --- a/tests/lint/rules/detached-impl/index.test.ts +++ b/tests/lint/rules/detached-impl/index.test.ts @@ -13,6 +13,13 @@ test("reports a step written on a companion another file declares", async () => ]); }); +test("reports `.implCreate.noseal` as the step it hangs from", async () => { + expect(await lint("noseal")).toEqual([{ rule: DetachedImpl, at: "noseal/consumer.ts:3:28" }]); + expect(await messages("noseal")).toEqual([ + "Age.implCreate is written outside the chain that declares it; write the step there", + ]); +}); + test("reports a step written on one variant of an enum", async () => { expect(await lint("enum")).toEqual([{ rule: DetachedImpl, at: "enum/consumer.ts:3:14" }]); expect(await messages("enum")).toEqual([ diff --git a/tests/lint/rules/unimplemented-trait/fixtures/implemented.ts b/tests/lint/rules/unimplemented-trait/fixtures/implemented.ts index 2bb7093..4256a73 100644 --- a/tests/lint/rules/unimplemented-trait/fixtures/implemented.ts +++ b/tests/lint/rules/unimplemented-trait/fixtures/implemented.ts @@ -6,3 +6,8 @@ const Greetable = Trait.companion(); export type User = Val<"User", { name: string }, Behaviours>; export const User = Val.companion().implTrait(Greetable).implTrait<((Named))>({}); + +export type Guest = Val<"Guest", { name: string }, Named>; +export const Guest = Val.companion() + .implCreate.noseal(() => ({ name: "guest" })) + .implTrait({}); diff --git a/tests/lint/rules/unimplemented-trait/index.test.ts b/tests/lint/rules/unimplemented-trait/index.test.ts index bd49869..c23a3c9 100644 --- a/tests/lint/rules/unimplemented-trait/index.test.ts +++ b/tests/lint/rules/unimplemented-trait/index.test.ts @@ -17,7 +17,7 @@ test("reports each Trait a Val declares but does not implement", async () => { ]); }); -test("accepts both implTrait forms and every step in a chain", async () => { +test("accepts both implTrait forms and every step in a chain, `.implCreate.noseal` included", async () => { expect(await lint("implemented")).toEqual([]); }); diff --git a/tests/val.production.test.ts b/tests/val.production.test.ts index e5e671f..4f3df3c 100644 --- a/tests/val.production.test.ts +++ b/tests/val.production.test.ts @@ -62,3 +62,20 @@ describe("Val nocopy in production", () => { expect(changed.count).toBe(2); }); }); + +describe("implCreate.noseal in production", () => { + test("skips the custom seal", () => { + type SectionId = Value<"ProductionSectionId", string>; + let sealed = 0; + const SectionId = Val.companion() + .implCreate.noseal(() => "0123abcd") + .implSeal((text, seal): SectionId | undefined => { + sealed += 1; + return seal(text); + }); + + expect(SectionId.create()).toBe("0123abcd"); + expect(SectionId.create.nocopy()).toBe("0123abcd"); + expect(sealed).toBe(0); + }); +}); diff --git a/tests/val.test.ts b/tests/val.test.ts index 3f78bf2..1da1c35 100644 --- a/tests/val.test.ts +++ b/tests/val.test.ts @@ -1625,10 +1625,10 @@ describe("building", () => { describe("implCreate", () => { test("create and seal retain their nocopy terminal operation", () => { type Dog = Val<"Dog", { name: string }>; - let minted = 0; + let calls = 0; const Dog = Val.companion() .implCreate((name: string) => { - minted += 1; + calls += 1; return { name }; }) .implSeal((dog, seal) => seal({ name: dog.name.trim() })); @@ -1639,7 +1639,7 @@ describe("building", () => { expect(Object.isFrozen(sealed)).toBe(true); const created = Dog.create.nocopy(" spot "); expect(created).toEqual({ name: "spot" }); - expect(minted).toBe(1); + expect(calls).toBe(1); expectTypeOf(Dog.seal.nocopy).parameters.toEqualTypeOf<[SeedOf]>(); expectTypeOf(Dog.create.nocopy).returns.toEqualTypeOf(); }); @@ -1669,11 +1669,11 @@ describe("building", () => { expectTypeOf(Score.create(1)).toEqualTypeOf>(); }); - test("nocopy preserves Result behavior and adopts the minter's payload", () => { + test("nocopy preserves Result behavior and adopts the created payload", () => { type Score = Val<"NocopyScore", { points: number }>; - let minted: SeedOf | undefined; + let created: SeedOf | undefined; const Score = Val.companion() - .implCreate((points: number) => (minted = { points })) + .implCreate((points: number) => (created = { points })) .implSeal((seed, seal): Result => seed.points >= 0 ? { ok: true, value: seal(seed) } @@ -1682,11 +1682,11 @@ describe("building", () => { const copied = Score.create(1); expect(copied.ok).toBe(true); - if (copied.ok) expect(copied.value).not.toBe(minted); + if (copied.ok) expect(copied.value).not.toBe(created); const adopted = Score.create.nocopy(2); expect(adopted.ok).toBe(true); - if (adopted.ok) expect(adopted.value).toBe(minted); + if (adopted.ok) expect(adopted.value).toBe(created); expect(Score.create.nocopy(-1)).toEqual({ ok: false, @@ -1707,11 +1707,11 @@ describe("building", () => { type Member = Val<"Member", { id: string; name: string }>; type Fields = Omit, "id">; - let minted = 0; + let created = 0; const Member = Val.companion() .implCreate((f: Fields): SeedOf => { - minted += 1; - return { id: `id-${minted}`, ...f }; + created += 1; + return { id: `id-${created}`, ...f }; }) .implSeal((m: SeedOf, seal) => seal(m)); @@ -1720,9 +1720,71 @@ describe("building", () => { const renamed = Member.patch(m, { name: "sue" }); expect(renamed).toEqual({ id: "id-1", name: "sue" }); - expect(minted).toBe(1); + expect(created).toBe(1); expectTypeOf(renamed).toEqualTypeOf(); }); + + describe("noseal", () => { + type SectionId = Val<"SectionId", string>; + const hex = (text: string, seal: (text: string) => SectionId): SectionId | undefined => + /^[0-9a-f]{8}$/.test(text) ? seal(text) : undefined; + + test("create returns the Val, not what the seal returns", () => { + const SectionId = Val.companion() + .implCreate.noseal(() => "0123abcd") + .implSeal((text, seal): Result => ({ ok: true, value: seal(text) })); + + expect(SectionId.create()).toBe("0123abcd"); + expectTypeOf(SectionId.create).returns.toEqualTypeOf(); + }); + + test("throws in development when the seal rejects the created payload or normalization changes it", () => { + type Cents = Val<"NosealCents", number>; + const rejected = Val.companion() + .implCreate.noseal(() => "not hex") + .implSeal(hex); + const changed = Val.companion() + .implCreate.noseal(() => 1.5) + .implSeal((n: number, seal): Cents | undefined => seal(Math.round(n))); + + const message = /payload from `\.implCreate\.noseal`/; + expect(() => rejected.create()).toThrow(message); + expect(() => rejected.create.nocopy()).toThrow(message); + expect(() => changed.create()).toThrow(message); + }); + + test("a later implCreate goes through the seal again", () => { + const SectionId = Val.companion() + .implSeal((): SectionId | undefined => undefined) + .implCreate.noseal(() => "0123abcd") + .implCreate(() => "0123abcd"); + + expect(SectionId.create()).toBeUndefined(); + }); + + test("nocopy adopts the created payload", () => { + type Doc = Val<"NosealAdopted", { label: string }>; + let created: SeedOf | undefined; + const Doc = Val.companion() + .implCreate.noseal(() => (created = { label: "x" })) + .implSeal((d, seal): Doc | undefined => seal(d)); + + expect(Doc.create.nocopy()).toBe(created); + expect(Doc.create()).not.toBe(created); + }); + + test("create takes no arguments", () => { + const builder = Val.companion(); + // @ts-expect-error a required argument would reach the payload unsealed + builder.implCreate.noseal((text: string) => text); + // @ts-expect-error an optional one too + builder.implCreate.noseal((text?: string) => text ?? "0123abcd"); + // @ts-expect-error and a rest parameter + builder.implCreate.noseal((...texts: string[]) => texts.join("")); + // @ts-expect-error nor return another payload + builder.implCreate.noseal(() => 5); + }); + }); }); describe("fixed", () => { @@ -1731,17 +1793,17 @@ describe("building", () => { // The counter is closed over per companion, so a test never reads another test's ids. const account = () => { - let minted = 0; + let created = 0; return Val.companion() .implCreate((f: Fields): SeedOf => { - minted += 1; - return { id: `id-${minted}`, ...f }; + created += 1; + return { id: `id-${created}`, ...f }; }) .implSeal((a, seal) => seal({ ...a, owner: a.owner.trim().toLowerCase() })) .fixed<"id">(); }; - test("create mints the id, and `patch` preserves it", () => { + test("create generates the id, and `patch` preserves it", () => { const Account = account(); const a = Account.create({ owner: "bob", note: "" }); expect(a.id).toBe("id-1"); @@ -1759,16 +1821,6 @@ describe("building", () => { Account.patch(a, { id: "forged" }); }); - test("retains nocopy on seal and create", () => { - const Account = account(); - const seed = { id: "given", owner: " alice ", note: "" } as const; - - const sealed = Account.seal.nocopy(seed); - expect(sealed).not.toBe(seed); - expect(sealed.owner).toBe("alice"); - expect(Account.create.nocopy({ owner: " bob ", note: "" }).owner).toBe("bob"); - }); - test("keys must exist on the payload", () => { // @ts-expect-error there is no such field Val.companion().fixed<"nope">();