From 24ffa9ece854301b10c6e20150e9aca05be07d9e Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi Date: Thu, 24 Sep 2026 16:50:17 +0900 Subject: [PATCH 1/2] fix: strip nested brands in PayloadOf - `Val.unwrap` returns it too, so seal each nested Val before sealing the unwrapped payload again --- scripts/type-perf/fixtures/core.ts | 7 ++-- scripts/type-perf/fixtures/enum.ts | 4 +-- src/val.ts | 15 +++++---- tests/enum.test.ts | 6 ++++ tests/val.test.ts | 52 +++++++++++++++++++++++------- 5 files changed, 62 insertions(+), 22 deletions(-) diff --git a/scripts/type-perf/fixtures/core.ts b/scripts/type-perf/fixtures/core.ts index 148980d..a761a9c 100644 --- a/scripts/type-perf/fixtures/core.ts +++ b/scripts/type-perf/fixtures/core.ts @@ -1,6 +1,6 @@ // Core API under `vp run type-perf`. Deep payloads, tuples, records and every builder step, // because those are where the recursive conditionals run. -import { equals, Val, type Patch, type PayloadOf, type SeedOf } from "valof"; +import { equals, Val, type Patch, type SeedOf } from "valof"; type City = Val<"City", { name: string; zip?: string }>; const City = Val.sealer(); @@ -46,7 +46,7 @@ const Order = Val.companion() }); declare const order: Order; -declare const patch: Patch>; +declare const patch: Patch>; export const derived = [ // The patch stops at a nested Val, so the deep one runs inside `Address`. @@ -64,7 +64,8 @@ export const derived = [ lines: [Line({ sku: "a", qty: 1, unit: Money({ amount: 1, currency: "JPY" }), tags: ["x"] })], totals: {}, }), - Val.of(Val.unwrap(order)), + Val.of(order), + Val.unwrap(order), ]; export const equal = [equals(order, order), equals(order.totals["vat"]!, order.totals["vat"]!)]; diff --git a/scripts/type-perf/fixtures/enum.ts b/scripts/type-perf/fixtures/enum.ts index 678c00c..0aeac2f 100644 --- a/scripts/type-perf/fixtures/enum.ts +++ b/scripts/type-perf/fixtures/enum.ts @@ -1,7 +1,7 @@ // 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. -import { Val, type Patch, type PayloadOf } from "valof"; +import { Val, type Patch, type SeedOf } from "valof"; import { Enum, Trait, type Dyn, type Self, type Tag, type VariantOf } from "valof/experimental"; type Shape = Enum< @@ -50,7 +50,7 @@ type Frame = Val<"Frame", { id: string; shape: Shape; last: Event }>; const Frame = Val.sealer(); declare const shape: Shape; -declare const patch: Patch>; +declare const patch: Patch>; const circle = Shape.Circle({ id: "c", r: 2 }); const click = Event.Click.create({ id: "e", x: 1, y: 2 }) as VariantOf; diff --git a/src/val.ts b/src/val.ts index 07a6135..96b0441 100644 --- a/src/val.ts +++ b/src/val.ts @@ -212,12 +212,15 @@ export type BrandOf = V extends Phantom ? K /** The payload as the declaration wrote it, {@link Rec} markers included. */ type Declared = V extends Phantom ? T : never; -/** The Val's payload type, with every {@link Rec} opened to the Val it references. */ +/** + * The Val's payload type, with the brand removed at every depth: a nested Val, and the one a + * {@link Rec} references, is its payload too. + */ export type PayloadOf = Opened>; -// Every {@link Rec} opened, and nothing else changed: the `readonly` the declaration wrote stays, -// and so does the mutability {@link Val.unwrap} hands back. The marker is the declaration's, so no -// type derived from the payload may carry it. +// Every nested Val and {@link Rec} opened to its payload, and nothing else changed: the `readonly` +// the declaration wrote stays, and so does the mutability {@link Val.unwrap} hands back. The marker +// is the declaration's, so no type derived from the payload may carry it. // // One mapped type covers an array and a tuple both: homomorphic over either, it keeps the length, // the positions and the mutability. {@link DeepReadonly} needs the two apart only because it @@ -225,10 +228,10 @@ export type PayloadOf = Opened>; type Opened = IsRec extends true ? T extends Rec - ? V + ? Opened> : T : [T] extends [AnyVal] - ? T + ? Opened> : [T] extends [Primitive] ? T : number extends keyof T diff --git a/tests/enum.test.ts b/tests/enum.test.ts index eb02bd8..b18dd02 100644 --- a/tests/enum.test.ts +++ b/tests/enum.test.ts @@ -226,6 +226,12 @@ describe("patch", () => { Holder.patch(holder, { shape: { r: 3 } }); expect(Holder.patch(holder, { shape: square }).shape).toEqual({ side: 3, _tag: "Square" }); }); + + test("unwraps to the payload of each variant", () => { + expectTypeOf(Val.unwrap(holder).shape).toEqualTypeOf< + { r: number; _tag: "Circle" } | { side: number; _tag: "Square" } + >(); + }); }); }); diff --git a/tests/val.test.ts b/tests/val.test.ts index 98cd896..89098f6 100644 --- a/tests/val.test.ts +++ b/tests/val.test.ts @@ -196,7 +196,31 @@ describe("Val", () => { const raw = Val.unwrap(order); expect(raw).toEqual({ id: "o", total: { amount: 1, currency: "JPY" } }); - expect(equals(raw.total, Money({ amount: 1, currency: "JPY" }))).toBe(true); + expectTypeOf(raw).toEqualTypeOf<{ + id: string; + total: { amount: number; currency: string }; + }>(); + }); + + test("a Val nested two deep comes back as data too", () => { + type Money = Val<"Money", { amount: number; currency: string }>; + type Line = Val<"Line", { price: Money }>; + type Order = Val<"Order", { line: Line }>; + expectTypeOf>().toEqualTypeOf<{ + line: { price: { amount: number; currency: string } }; + }>(); + }); + + test("a nested Val must pass its own seal again", () => { + type Money = Val<"Money", { amount: number; currency: string }>; + type Order = Val<"Order", { id: string; total: Money }>; + const Money = Val.sealer(); + const Order = Val.sealer(); + const raw = Val.unwrap(Order({ id: "o", total: Money({ amount: 1, currency: "JPY" }) })); + + // @ts-expect-error `total` is data now, not a Money + Order(raw); + Order({ ...raw, total: Money(raw.total) }); }); }); @@ -472,16 +496,24 @@ describe("Val", () => { }); test("the payload and the mutable copy open it too", () => { - expectTypeOf>().toEqualTypeOf<{ value: number; children: Tree[] }>(); + expectTypeOf>().toEqualTypeOf<{ + value: number; + children: PayloadOf[]; + }>(); const raw = Val.unwrap(Tree({ value: 2, children: [Tree({ value: 1, children: [] })] })); - raw.children.push(Tree({ value: 3, children: [] })); + raw.children.push({ value: 3, children: [] }); expect(raw.children.map((child) => child.value)).toEqual([1, 3]); }); + test("a Rec inside another Val opens too", () => { + type Forest = Val<"app/Forest", { tree: Tree }>; + expectTypeOf["tree"]["children"]>().toEqualTypeOf[]>(); + }); + test("a readonly array in the declaration stays readonly", () => { type Chain = Val<"app/Chain", { links: readonly Rec[] }>; - expectTypeOf>().toEqualTypeOf<{ links: readonly Chain[] }>(); + expectTypeOf>().toEqualTypeOf<{ links: readonly PayloadOf[] }>(); }); test("Rec reaches through a record and a nested object", () => { @@ -768,18 +800,16 @@ describe("copying", () => { const raw = Val.unwrap(derived); expect(raw.lines).not.toBe(derived.lines); expect(raw.lines[0]).not.toBe(derived.lines[0]); - // `PayloadOf` keeps a nested Val a Val, so the write goes through an untyped view: the - // claim under test is about what the copy shares at runtime, not about its type. - (raw.lines[0] as unknown as { qty: number }).qty = 999; + raw.lines[0]!.qty = 999; expect(derived.lines[0]!.qty).toBe(1); }); test("an unwrapped payload is not adopted when it is sealed again", () => { const raw = Val.unwrap(order()); - const resealed = Order(raw); - expect(resealed.lines).not.toBe(raw.lines); - (raw.lines[0] as unknown as { qty: number }).qty = 999; - expect(resealed.lines[0]!.qty).toBe(1); + const line = Line(raw.lines[0]!); + expect(line).not.toBe(raw.lines[0]); + raw.lines[0]!.qty = 999; + expect(line.qty).toBe(1); }); test("a write into a reused node is caught in development", () => { From 24b44faf7bb22ee44788a805ea5a156e4b1b6103 Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi Date: Thu, 24 Sep 2026 16:50:18 +0900 Subject: [PATCH 2/2] docs: say that PayloadOf and Val.unwrap strip nested brands --- docs/src/api.md | 24 ++++++++++++------------ docs/src/caveats.md | 3 ++- docs/src/utilities.md | 2 +- notes/design.md | 19 ++++++++++++++++++- 4 files changed, 33 insertions(+), 15 deletions(-) diff --git a/docs/src/api.md b/docs/src/api.md index 54cbc68..6eb1961 100644 --- a/docs/src/api.md +++ b/docs/src/api.md @@ -35,18 +35,18 @@ ## Types -| | | -| --------------------- | ----------------------------------------------- | -| `Val` | a branded value type | -| `AnyVal` | a constraint over any Val | -| `SeedOf` | the payload accepted by constructors and seals | -| `PayloadOf` | the payload behind the brand | -| `Patch` | the patch accepted for a payload | -| `Rec` | a Val's reference to itself, in its own payload | -| `Sealer` | a callable sealer with every step still open | -| `Sealed` | a finished callable companion | -| `CompanionBuilder` | a companion with every step still open | -| `Companion` | a finished companion | +| | | +| --------------------- | -------------------------------------------------- | +| `Val` | a branded value type | +| `AnyVal` | a constraint over any Val | +| `SeedOf` | the payload accepted by constructors and seals | +| `PayloadOf` | the payload, with the brand removed at every depth | +| `Patch` | the patch accepted for a payload | +| `Rec` | a Val's reference to itself, in its own payload | +| `Sealer` | a callable sealer with every step still open | +| `Sealed` | a finished callable companion | +| `CompanionBuilder` | a companion with every step still open | +| `Companion` | a finished companion | You never write the last four. They are exported so that your own `.d.ts` can name them when you re-export a companion. diff --git a/docs/src/caveats.md b/docs/src/caveats.md index 661b8f3..b7ac697 100644 --- a/docs/src/caveats.md +++ b/docs/src/caveats.md @@ -39,7 +39,8 @@ const user = User(plain); // sealed, and now it is one ``` `PayloadOf` removes the brand from the type, not from the value, so it costs nothing at run time. -`Val.unwrap` copies and drops `readonly` too, which a request body does not need. +`Val.unwrap` copies and drops `readonly` too, which a request body does not need. Both also remove +the brand of a nested Val. Persistence helpers can create the same hole. [Jotai's `atomWithStorage`](https://jotai.org/docs/utilities/storage), for example, parses stored diff --git a/docs/src/utilities.md b/docs/src/utilities.md index 18dd3e6..276f08a 100644 --- a/docs/src/utilities.md +++ b/docs/src/utilities.md @@ -50,7 +50,7 @@ companion or specifying no type. ## `Val.unwrap` A plain, mutable deep copy of the payload, to pass to code that does not know about `readonly`. It -strips the brand as well. +strips the brand at every depth. ```ts // @errors: 2339 diff --git a/notes/design.md b/notes/design.md index 652683c..3404e27 100644 --- a/notes/design.md +++ b/notes/design.md @@ -183,6 +183,10 @@ hono 4.13.7 で実測。Val をそのまま返すと受け側の `const a: User `Val.unwrap` ではないのがポイント。あれは `readonly` も外して可変コピーを返す。API に渡すのに可変性は要ら ない。求めていたのは「コピーしない型だけの unwrap」で、それは `PayloadOf` として既にある。 +**ネスト Val のブランドも落とす。** 以前の `PayloadOf` はネスト Val で止まっていた。`PayloadOf` +の `total` は `Money` のまま生成クライアントに届き、受け側の `const m: Money = res.total` が素通りした。 +今はネスト Val も `Rec` も、それぞれの payload に開く。 + 残る穴は、サーバ側で注釈を書き忘れれば漏れること。ただし規律が「全フレームワークの全呼び出し側」から 「エンドポイントごとに 1 行」に縮み、しかも書く場所が seal の定義されている側になる。 @@ -454,7 +458,8 @@ export type Node = Val<"app/Node", { value: number; next?: Rec }>; エイリアスの解決そのものは verdict(§11.2)のぶん軽くなったので、かつての TS2456 は出ない。 `DeepReadonly` が `Rec` を `V` に開くので(§4.3)、**`Rec` が見えるのは宣言の 1 行だけ**である。値にも -seed にも `patch` にも残らない。`PayloadOf` も開く。開かないと `Val.unwrap` が返す payload の要素が +seed にも `patch` にも残らない。`PayloadOf` も開くが、開く先は `Tree` ではなく `PayloadOf` で +ある(ネスト Val と同じ、§4.1「既知の摩擦」)。開かないと `Val.unwrap` が返す payload の要素が `Rec` のままで、読めない。`SeedOf` は宣言から直接 `DeepReadonly` を通すので、二重に歩かない。 ```ts @@ -724,6 +729,18 @@ README の `Reusing a Val` が見せているのは**トップレベルでの合 `readonly T[]` は `T[]` に代入できない。`readonly` を知らないサードパーティ関数に渡すたびに詰まる(`Array.prototype.sort` すら通らない)。`Val.unwrap` で可変なコピーを取り出す。`Val.of` の逆向きで、実装は `copy` の再利用。 +`Val.unwrap` の戻り型は `PayloadOf` なので、ネスト Val のブランドも落ちる。実行時もネスト Val まで +コピーするので、返る子は seal を通っていない。代償として、ネスト Val を持つ payload では +`Val.of(Val.unwrap(v))` も `Order(Val.unwrap(o))` も型エラーになる。子を seal し直す。 + +```ts +const raw = Val.unwrap(order); +Order({ ...raw, total: Money(raw.total) }); +``` + +**却下: `unwrap` だけネスト Val を残す。** 往復は型が通るが、`PayloadOf` と別に payload 型がもう 1 つ +要り、`unwrap` の戻り型が `PayloadOf` でなくなる。 + `unwrap` の実装を `structuredClone` に差し替えると **gzip が 9 B 減り、テスト 102 件は全部通る**。`copy` の再利用は owning フラグを要求し、`structuredClone` ならフラグごと消せるため。正しさの差も見つからなかった(`__proto__` を own property に持つ payload は own のまま複製され汚染もしない、null プロトタイプは `Object.prototype` になる、frozen な値の複製は frozen ではない)。残る差は速度だけで、そこは大きい(381 vs 2,587 ns/op、6.8 倍)。**9 B のために `unwrap` を 7 倍遅くする取引なので `copy` の再利用を維持する。** エラーメッセージが `readonly` の入れ子で膨れて読みにくくなる、という摩擦も残る。