diff --git a/notes/design.md b/notes/design.md index 2310772..9d6da80 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(鋳造)の分離 / **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 では再現しない) @@ -1544,6 +1544,43 @@ type Rebuild = (value: V, arg: Arg) => Constructed; ペイロード自体が `string` の Val(`Val<"Email", string>`)は除外する。ペイロードとワイヤ形式を型で区別する手段がない。 +#### seal は同期で返す、2026-10-14 + +seal の戻り値は `unknown` だったので、async の seal が型を通っていた。禁じる。 + +```ts +.implSeal(async (r, seal) => seal(await normalize(r))); // 型エラー +``` + +- **派生がすべて async になる。**`patch` は seal の戻り値をそのまま返すので、`seal` も `patch` も + `Promise` を返す +- **async はたいてい I/O で、冪等の規約と合わない。**一意性の確認は、既にある値自身の payload を拒否する。 + ハッシュ化は 2 回掛けると別の値になる。値の取得は seal の外で済ませ、結果を引数で渡す +- **検査とコピーのあいだに await が入る。**コピーは末端の既定の seal で取る(§6.8)。await のあいだに + 呼び出し側が入力を書き換えられる。ただ、書き換えはコードを読めば気づける。development は + `changedDuringSeal` で検出するが、production は検査しない + +`Synchronous` が、戻り型に `PromiseLike` を含む seal を `Invalid<…>` で落とす。 +`Val.companion`、`Enum.companion`、Variant の `implSeal` に掛かる。payload が `string` の Val も +除外しない。除外されるのは引数の検査だけである。 + +型が見るのは `PromiseLike` であって、`then` を持つ任意の型ではない。`then` のシグネチャが合わない +オブジェクトは型を通る。 + +**戻り型が `any` の seal は通す。**`any` は `PromiseLike` にも合うので、除外しないと同期の seal に +「a seal is synchronous」という嘘のエラーが出る。 + +`create` は元から async にできない。minter の戻り型が `SeedOf` で、`Promise` はそこに代入できない。 +例外は空の payload(`Record`)で、`Promise` が代入できるので通る。塞いでいない。 + +**却下: development で、カスタム seal が return した後に既定の seal が呼ばれたら投げる。**型を通る +遅延評価(`(r, seal) => () => seal(r)`)と、戻り型を `any` にした async の seal を捕まえる。一度入れて +外した。過剰防御である。seal は検査と正規化をすると分かっていれば、どちらも書かない。await のあいだの +入力の書き換えは、今も `changedDuringSeal` が development で検出する。 + +production のバンドルは変わらない(852 B gzip)。型のインスタンス化は core が 5,665 → 5,781、enum が +33,264 → 33,537。 + #### sealer には `create` を生やさない sealer は callable なので `create` を足しても構築経路は 1 本も絞られず、**得られるのは名前空間だけ**である。問題は、**名前が果たせない約束をする**こと。 diff --git a/src/enum.ts b/src/enum.ts index 46fd4d3..0a3baf7 100644 --- a/src/enum.ts +++ b/src/enum.ts @@ -10,6 +10,7 @@ import { type Invalid, type Passes, type Patch, + type Synchronous, type Takes, type Wired, } from "./val.ts"; @@ -302,9 +303,12 @@ type VariantSealImpl, FE> = ( * The seal's parameter may be wider than the payload, so a schema library can parse into it, but * not so wide that it accepts a wire format. See `Val`'s `implSeal`. */ -type CheckedSeal unknown> = [string] extends [Parameters[0]] - ? Invalid<"a seal takes the payload, not a wire format; decode before sealing"> - : G; +type CheckedSeal unknown> = Synchronous< + G, + [string] extends [Parameters[0]] + ? Invalid<"a seal takes the payload, not a wire format; decode before sealing"> + : G +>; /** * A variant's frame on a companion: `create`, `patch`, a `seal` where one was registered, and diff --git a/src/val.ts b/src/val.ts index 7ead6e8..aad3b3e 100644 --- a/src/val.ts +++ b/src/val.ts @@ -404,11 +404,22 @@ type SealImpl = (value: SeedOf, seal: (value: SeedOf) => * `Record` alone. A string payload cannot be told apart from a wire format, so * it is exempt. */ -type CheckedSeal> = [string] extends [SeedOf] - ? G - : [string] extends [Parameters[0]] - ? Invalid<"a seal takes the payload, not a wire format; decode before sealing"> - : G; +type CheckedSeal> = Synchronous< + G, + [string] extends [SeedOf] + ? G + : [string] extends [Parameters[0]] + ? Invalid<"a seal takes the payload, not a wire format; decode before sealing"> + : G +>; + +export type Synchronous unknown, Checked> = + // `any` is exempt. It matches a promise as it matches everything, so it shows nothing. + 0 extends 1 & ReturnType + ? Checked + : [Extract, PromiseLike>] extends [never] + ? Checked + : Invalid<"a seal is synchronous; await before sealing">; /** * What `create` must be: any arguments, a payload out. The result is not a value yet. The type's diff --git a/tests/enum.test.ts b/tests/enum.test.ts index d2ac9af..d9de3c8 100644 --- a/tests/enum.test.ts +++ b/tests/enum.test.ts @@ -612,6 +612,15 @@ describe("a seal of its own", () => { .implSeal((p, seal) => seal(p)); }); + test("a seal whose return can be awaited is rejected", () => { + // @ts-expect-error a seal is synchronous + Enum.companion().implSeal(async (p, seal) => seal(p)); + Enum.companion().implVariant("Cash", (variant) => + // @ts-expect-error the same for a variant's seal + variant.implSeal(async (p, seal) => seal(p)), + ); + }); + test("a variant seals alone where the enum wrote none", () => { const Plain = Enum.companion() .implVariant("Circle", (variant) => diff --git a/tests/val.test.ts b/tests/val.test.ts index 30a3052..3f78bf2 100644 --- a/tests/val.test.ts +++ b/tests/val.test.ts @@ -1503,19 +1503,6 @@ describe("building", () => { expect(Name.seal({ value: " Alice " })).toEqual({ value: "alice" }); }); - test("detects input mutation before an awaited default seal", async () => { - type Reading = Val<"Reading", { n: number }>; - const Reading = Val.companion().implSeal(async (r, seal) => { - await Promise.resolve(); - return seal(r); - }); - const input = { n: 1 }; - - const sealed = Reading.seal(input); - input.n = 2; - await expect(sealed).rejects.toThrow(TypeError); - }); - test("reuses the starting snapshot for every default seal call", () => { type Reading = Val<"Reading", { n: number }>; const Reading = Val.companion().implSeal((r: { n: number }, seal) => { @@ -1615,6 +1602,24 @@ describe("building", () => { expect(Email.seal("a@b.com")).toBe("a@b.com"); }); }); + + describe("the seal's return", () => { + test("a return that can be awaited is rejected", () => { + // @ts-expect-error a seal is synchronous + Val.companion().implSeal(async (n, seal) => seal(n)); + // @ts-expect-error a string payload is exempt from the parameter check alone + Val.companion>().implSeal(async (e, seal) => seal(e)); + // @ts-expect-error a union that includes a promise is the same + Val.companion().implSeal((n, seal) => (n > 0 ? seal(n) : Promise.resolve(seal(n)))); + // @ts-expect-error so is a `PromiseLike`, which an async result type implements + Val.companion().implSeal((n, seal) => Promise.resolve(seal(n)) as PromiseLike); + }); + + test("a return typed `any` is accepted", () => { + // oxlint-disable-next-line no-explicit-any -- the case under test + Val.companion().implSeal((n, seal): any => seal(n)); + }); + }); }); describe("implCreate", () => {