Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 12 additions & 12 deletions docs/src/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,18 +35,18 @@

## Types

| | |
| --------------------- | ----------------------------------------------- |
| `Val<K, T>` | a branded value type |
| `AnyVal` | a constraint over any Val |
| `SeedOf<V>` | the payload accepted by constructors and seals |
| `PayloadOf<V>` | the payload behind the brand |
| `Patch<T>` | the patch accepted for a payload |
| `Rec<V>` | a Val's reference to itself, in its own payload |
| `Sealer<V>` | a callable sealer with every step still open |
| `Sealed<V, M>` | a finished callable companion |
| `CompanionBuilder<V>` | a companion with every step still open |
| `Companion<V, M>` | a finished companion |
| | |
| --------------------- | -------------------------------------------------- |
| `Val<K, T>` | a branded value type |
| `AnyVal` | a constraint over any Val |
| `SeedOf<V>` | the payload accepted by constructors and seals |
| `PayloadOf<V>` | the payload, with the brand removed at every depth |
| `Patch<T>` | the patch accepted for a payload |
| `Rec<V>` | a Val's reference to itself, in its own payload |
| `Sealer<V>` | a callable sealer with every step still open |
| `Sealed<V, M>` | a finished callable companion |
| `CompanionBuilder<V>` | a companion with every step still open |
| `Companion<V, M>` | 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.
Expand Down
3 changes: 2 additions & 1 deletion docs/src/caveats.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ const user = User(plain); // sealed, and now it is one
```

`PayloadOf<V>` 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
Expand Down
2 changes: 1 addition & 1 deletion docs/src/utilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 18 additions & 1 deletion notes/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,10 @@ hono 4.13.7 で実測。Val をそのまま返すと受け側の `const a: User
`Val.unwrap` ではないのがポイント。あれは `readonly` も外して可変コピーを返す。API に渡すのに可変性は要ら
ない。求めていたのは「コピーしない型だけの unwrap」で、それは `PayloadOf` として既にある。

**ネスト Val のブランドも落とす。** 以前の `PayloadOf` はネスト Val で止まっていた。`PayloadOf<Order>`
の `total` は `Money` のまま生成クライアントに届き、受け側の `const m: Money = res.total` が素通りした。
今はネスト Val も `Rec` も、それぞれの payload に開く。

残る穴は、サーバ側で注釈を書き忘れれば漏れること。ただし規律が「全フレームワークの全呼び出し側」から
「エンドポイントごとに 1 行」に縮み、しかも書く場所が seal の定義されている側になる。

Expand Down Expand Up @@ -454,7 +458,8 @@ export type Node = Val<"app/Node", { value: number; next?: Rec<Node> }>;
エイリアスの解決そのものは verdict(§11.2)のぶん軽くなったので、かつての TS2456 は出ない。

`DeepReadonly` が `Rec<V>` を `V` に開くので(§4.3)、**`Rec` が見えるのは宣言の 1 行だけ**である。値にも
seed にも `patch` にも残らない。`PayloadOf` も開く。開かないと `Val.unwrap` が返す payload の要素が
seed にも `patch` にも残らない。`PayloadOf` も開くが、開く先は `Tree` ではなく `PayloadOf<Tree>` で
ある(ネスト Val と同じ、§4.1「既知の摩擦」)。開かないと `Val.unwrap` が返す payload の要素が
`Rec<Tree>` のままで、読めない。`SeedOf` は宣言から直接 `DeepReadonly` を通すので、二重に歩かない。

```ts
Expand Down Expand Up @@ -724,6 +729,18 @@ README の `Reusing a Val` が見せているのは**トップレベルでの合

`readonly T[]` は `T[]` に代入できない。`readonly` を知らないサードパーティ関数に渡すたびに詰まる(`Array.prototype.sort` すら通らない)。`Val.unwrap` で可変なコピーを取り出す。`Val.of` の逆向きで、実装は `copy` の再利用。

`Val.unwrap` の戻り型は `PayloadOf<V>` なので、ネスト 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` の入れ子で膨れて読みにくくなる、という摩擦も残る。
Expand Down
7 changes: 4 additions & 3 deletions scripts/type-perf/fixtures/core.ts
Original file line number Diff line number Diff line change
@@ -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<City>();
Expand Down Expand Up @@ -46,7 +46,7 @@ const Order = Val.companion<Order>()
});

declare const order: Order;
declare const patch: Patch<PayloadOf<Order>>;
declare const patch: Patch<SeedOf<Order>>;

export const derived = [
// The patch stops at a nested Val, so the deep one runs inside `Address`.
Expand All @@ -64,7 +64,8 @@ export const derived = [
lines: [Line({ sku: "a", qty: 1, unit: Money({ amount: 1, currency: "JPY" }), tags: ["x"] })],
totals: {},
}),
Val.of<Order>(Val.unwrap<Order>(order)),
Val.of<Order>(order),
Val.unwrap<Order>(order),
];

export const equal = [equals(order, order), equals(order.totals["vat"]!, order.totals["vat"]!)];
4 changes: 2 additions & 2 deletions scripts/type-perf/fixtures/enum.ts
Original file line number Diff line number Diff line change
@@ -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<
Expand Down Expand Up @@ -50,7 +50,7 @@ type Frame = Val<"Frame", { id: string; shape: Shape; last: Event }>;
const Frame = Val.sealer<Frame>();

declare const shape: Shape;
declare const patch: Patch<PayloadOf<Frame>>;
declare const patch: Patch<SeedOf<Frame>>;

const circle = Shape.Circle({ id: "c", r: 2 });
const click = Event.Click.create({ id: "e", x: 1, y: 2 }) as VariantOf<Event, "Click">;
Expand Down
15 changes: 9 additions & 6 deletions src/val.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,23 +212,26 @@ export type BrandOf<V extends AnyVal> = V extends Phantom<infer K, unknown> ? K
/** The payload as the declaration wrote it, {@link Rec} markers included. */
type Declared<V> = V extends Phantom<string, infer T> ? 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<V extends AnyVal> = Opened<Declared<V>>;

// 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
// rewrites them.
type Opened<T> =
IsRec<T> extends true
? T extends Rec<infer V>
? V
? Opened<Declared<V>>
: T
: [T] extends [AnyVal]
? T
? Opened<Declared<T>>
: [T] extends [Primitive]
? T
: number extends keyof T
Expand Down
6 changes: 6 additions & 0 deletions tests/enum.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
>();
});
});
});

Expand Down
52 changes: 41 additions & 11 deletions tests/val.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PayloadOf<Order>>().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<Money>();
const Order = Val.sealer<Order>();
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) });
});
});

Expand Down Expand Up @@ -472,16 +496,24 @@ describe("Val", () => {
});

test("the payload and the mutable copy open it too", () => {
expectTypeOf<PayloadOf<Tree>>().toEqualTypeOf<{ value: number; children: Tree[] }>();
expectTypeOf<PayloadOf<Tree>>().toEqualTypeOf<{
value: number;
children: PayloadOf<Tree>[];
}>();

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<PayloadOf<Forest>["tree"]["children"]>().toEqualTypeOf<PayloadOf<Tree>[]>();
});

test("a readonly array in the declaration stays readonly", () => {
type Chain = Val<"app/Chain", { links: readonly Rec<Chain>[] }>;
expectTypeOf<PayloadOf<Chain>>().toEqualTypeOf<{ links: readonly Chain[] }>();
expectTypeOf<PayloadOf<Chain>>().toEqualTypeOf<{ links: readonly PayloadOf<Chain>[] }>();
});

test("Rec reaches through a record and a nested object", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading