From e12401f20ff02a2cedac1f31b1771d82551f839f Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:44:09 +0900 Subject: [PATCH 1/5] feat!: require a seal to return synchronously - reject a `PromiseLike` return from `implSeal`, for Val, Enum and variant seals - throw in development when the default seal runs after the custom seal returned --- src/enum.ts | 10 +++++++--- src/val.ts | 32 ++++++++++++++++++++++++++------ tests/enum.test.ts | 9 +++++++++ tests/val.test.ts | 32 +++++++++++++++++++++++--------- 4 files changed, 65 insertions(+), 18 deletions(-) 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..b65fc4f 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 @@ -827,6 +838,10 @@ const changedDuringSeal = (): never => { throw new TypeError("The payload changed while the custom seal was running."); }; +const sealedLate = (): never => { + throw new TypeError("The default seal was called after the custom seal returned."); +}; + /** * Builds the recursive deep copy, in an owning and a non-owning form. * @@ -1026,7 +1041,10 @@ const attach = ( ? development ? (value) => { const before = snapshotSealInput(value); - return custom(value, (candidate) => { + // The type rejects a promise. This also catches a return that runs the seal lazily. + let returned = false; + const result = custom(value, (candidate) => { + if (returned) sealedLate(); if (before !== unsnapshotable) { const current = snapshotSealInput(value); if (current !== unsnapshotable && !equalsUnknown(before, current)) @@ -1040,6 +1058,8 @@ const attach = ( } return sealed; }); + returned = true; + return result; } : (value) => custom(value, terminal) : terminal; 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..1aeea13 100644 --- a/tests/val.test.ts +++ b/tests/val.test.ts @@ -1503,17 +1503,12 @@ describe("building", () => { expect(Name.seal({ value: " Alice " })).toEqual({ value: "alice" }); }); - test("detects input mutation before an awaited default seal", async () => { + test("rejects a default seal called after the custom seal returned", () => { 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 Reading = Val.companion().implSeal((r, seal) => () => seal(r)); - const sealed = Reading.seal(input); - input.n = 2; - await expect(sealed).rejects.toThrow(TypeError); + const later = Reading.seal({ n: 1 }); + expect(later).toThrow("The default seal was called after the custom seal returned."); }); test("reuses the starting snapshot for every default seal call", () => { @@ -1615,6 +1610,25 @@ 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 + const Loose = Val.companion().implSeal((n, seal): any => seal(n)); + expect(Loose.seal(1)).toBe(1); + }); + }); }); describe("implCreate", () => { From f3fc10bb968d17fcbe598e8e6cc10d69bd877be1 Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:51:43 +0900 Subject: [PATCH 2/5] test: cover a late default seal from a variant's seal --- tests/enum.test.ts | 8 ++++++++ tests/val.test.ts | 3 +-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/enum.test.ts b/tests/enum.test.ts index d9de3c8..5e66b38 100644 --- a/tests/enum.test.ts +++ b/tests/enum.test.ts @@ -621,6 +621,14 @@ describe("a seal of its own", () => { ); }); + test("a variant's seal that calls the default seal after returning throws", () => { + const Late = Enum.companion() + .implVariant("Cash", (variant) => variant.implSeal((p, seal) => () => seal(p)).impl()) + .impl(); + const later = Late.Cash.create({ id: "m1", yen: 1 }); + expect(later).toThrow("The default seal was called after the custom seal returned."); + }); + 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 1aeea13..ecd8fb8 100644 --- a/tests/val.test.ts +++ b/tests/val.test.ts @@ -1625,8 +1625,7 @@ describe("building", () => { test("a return typed `any` is accepted", () => { // oxlint-disable-next-line no-explicit-any -- the case under test - const Loose = Val.companion().implSeal((n, seal): any => seal(n)); - expect(Loose.seal(1)).toBe(1); + Val.companion().implSeal((n, seal): any => seal(n)); }); }); }); From 4a341ece0400f65f96d86eb420d505905fe25c81 Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:04:50 +0900 Subject: [PATCH 3/5] docs(notes): record the synchronous seal --- notes/design.md | 42 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/notes/design.md b/notes/design.md index 2310772..f70d47e 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,46 @@ 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 は検査しない + +塞ぐ場所は 2 つ。 + +- **型。**`Synchronous` が、戻り型に `PromiseLike` を含む seal を `Invalid<…>` で落とす。 + `Val.companion`、`Enum.companion`、Variant の `implSeal` に掛かる。payload が `string` の Val も + 除外しない。除外されるのは引数の検査だけである +- **development。**カスタム seal が return した後に既定の seal が呼ばれたら投げる。型を通る遅延評価 + (`(r, seal) => () => seal(r)`)がここで落ちる。Enum の seal は合成されて同じ `sealedWith` に届くので、 + Enum 側に実行時の検査は無い + +型が見るのは `PromiseLike` であって、`then` を持つ任意の型ではない。`then` のシグネチャが合わない +オブジェクトは型を通り、development の検査に任せる。 + +**戻り型が `any` の seal は通す。**`any` は `PromiseLike` にも合うので、除外しないと同期の seal に +「a seal is synchronous」という嘘のエラーが出る。 + +`create` は元から async にできない。minter の戻り型が `SeedOf` で、`Promise` はそこに代入できない。 +例外は空の payload(`Record`)で、`Promise` が代入できるので通る。塞いでいない。 + +development の検査に掛からないものが 1 つ残る。キャストで型を抜けた async の seal が、既定の seal を +呼ばずに reject する場合である。 + +production のバンドルは変わらない(852 B gzip)。型のインスタンス化は core が 5,665 → 5,781、enum が +33,264 → 33,537。 + #### sealer には `create` を生やさない sealer は callable なので `create` を足しても構築経路は 1 本も絞られず、**得られるのは名前空間だけ**である。問題は、**名前が果たせない約束をする**こと。 From 3149a9966c9383ed439ee4ea8dde4969d44ae248 Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:08:29 +0900 Subject: [PATCH 4/5] refactor: drop the development check for a default seal called late --- src/val.ts | 11 +---------- tests/enum.test.ts | 8 -------- tests/val.test.ts | 8 -------- 3 files changed, 1 insertion(+), 26 deletions(-) diff --git a/src/val.ts b/src/val.ts index b65fc4f..aad3b3e 100644 --- a/src/val.ts +++ b/src/val.ts @@ -838,10 +838,6 @@ const changedDuringSeal = (): never => { throw new TypeError("The payload changed while the custom seal was running."); }; -const sealedLate = (): never => { - throw new TypeError("The default seal was called after the custom seal returned."); -}; - /** * Builds the recursive deep copy, in an owning and a non-owning form. * @@ -1041,10 +1037,7 @@ const attach = ( ? development ? (value) => { const before = snapshotSealInput(value); - // The type rejects a promise. This also catches a return that runs the seal lazily. - let returned = false; - const result = custom(value, (candidate) => { - if (returned) sealedLate(); + return custom(value, (candidate) => { if (before !== unsnapshotable) { const current = snapshotSealInput(value); if (current !== unsnapshotable && !equalsUnknown(before, current)) @@ -1058,8 +1051,6 @@ const attach = ( } return sealed; }); - returned = true; - return result; } : (value) => custom(value, terminal) : terminal; diff --git a/tests/enum.test.ts b/tests/enum.test.ts index 5e66b38..d9de3c8 100644 --- a/tests/enum.test.ts +++ b/tests/enum.test.ts @@ -621,14 +621,6 @@ describe("a seal of its own", () => { ); }); - test("a variant's seal that calls the default seal after returning throws", () => { - const Late = Enum.companion() - .implVariant("Cash", (variant) => variant.implSeal((p, seal) => () => seal(p)).impl()) - .impl(); - const later = Late.Cash.create({ id: "m1", yen: 1 }); - expect(later).toThrow("The default seal was called after the custom seal returned."); - }); - 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 ecd8fb8..3f78bf2 100644 --- a/tests/val.test.ts +++ b/tests/val.test.ts @@ -1503,14 +1503,6 @@ describe("building", () => { expect(Name.seal({ value: " Alice " })).toEqual({ value: "alice" }); }); - test("rejects a default seal called after the custom seal returned", () => { - type Reading = Val<"Reading", { n: number }>; - const Reading = Val.companion().implSeal((r, seal) => () => seal(r)); - - const later = Reading.seal({ n: 1 }); - expect(later).toThrow("The default seal was called after the custom seal returned."); - }); - 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) => { From 57c79b601f982ff1227416db1f71733cb8afeae1 Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:18:11 +0900 Subject: [PATCH 5/5] docs(notes): record dropping the late-seal check --- notes/design.md | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/notes/design.md b/notes/design.md index f70d47e..9d6da80 100644 --- a/notes/design.md +++ b/notes/design.md @@ -1558,19 +1558,14 @@ seal の戻り値は `unknown` だったので、async の seal が型を通っ ハッシュ化は 2 回掛けると別の値になる。値の取得は seal の外で済ませ、結果を引数で渡す - **検査とコピーのあいだに await が入る。**コピーは末端の既定の seal で取る(§6.8)。await のあいだに 呼び出し側が入力を書き換えられる。ただ、書き換えはコードを読めば気づける。development は - `changedDuringSeal` で検出していたが、production は検査しない + `changedDuringSeal` で検出するが、production は検査しない -塞ぐ場所は 2 つ。 - -- **型。**`Synchronous` が、戻り型に `PromiseLike` を含む seal を `Invalid<…>` で落とす。 - `Val.companion`、`Enum.companion`、Variant の `implSeal` に掛かる。payload が `string` の Val も - 除外しない。除外されるのは引数の検査だけである -- **development。**カスタム seal が return した後に既定の seal が呼ばれたら投げる。型を通る遅延評価 - (`(r, seal) => () => seal(r)`)がここで落ちる。Enum の seal は合成されて同じ `sealedWith` に届くので、 - Enum 側に実行時の検査は無い +`Synchronous` が、戻り型に `PromiseLike` を含む seal を `Invalid<…>` で落とす。 +`Val.companion`、`Enum.companion`、Variant の `implSeal` に掛かる。payload が `string` の Val も +除外しない。除外されるのは引数の検査だけである。 型が見るのは `PromiseLike` であって、`then` を持つ任意の型ではない。`then` のシグネチャが合わない -オブジェクトは型を通り、development の検査に任せる。 +オブジェクトは型を通る。 **戻り型が `any` の seal は通す。**`any` は `PromiseLike` にも合うので、除外しないと同期の seal に 「a seal is synchronous」という嘘のエラーが出る。 @@ -1578,8 +1573,10 @@ seal の戻り値は `unknown` だったので、async の seal が型を通っ `create` は元から async にできない。minter の戻り型が `SeedOf` で、`Promise` はそこに代入できない。 例外は空の payload(`Record`)で、`Promise` が代入できるので通る。塞いでいない。 -development の検査に掛からないものが 1 つ残る。キャストで型を抜けた async の seal が、既定の seal を -呼ばずに reject する場合である。 +**却下: 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。