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
39 changes: 38 additions & 1 deletion notes/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 では再現しない)
Expand Down Expand Up @@ -1544,6 +1544,43 @@ type Rebuild<V, F, Arg> = (value: V, arg: Arg) => Constructed<V, F>;

ペイロード自体が `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<V>` を返す
- **async はたいてい I/O で、冪等の規約と合わない。**一意性の確認は、既にある値自身の payload を拒否する。
ハッシュ化は 2 回掛けると別の値になる。値の取得は seal の外で済ませ、結果を引数で渡す
- **検査とコピーのあいだに await が入る。**コピーは末端の既定の seal で取る(§6.8)。await のあいだに
呼び出し側が入力を書き換えられる。ただ、書き換えはコードを読めば気づける。development は
`changedDuringSeal` で検出するが、production は検査しない

`Synchronous<G, Checked>` が、戻り型に `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<V>` で、`Promise` はそこに代入できない。
例外は空の payload(`Record<never, never>`)で、`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 本も絞られず、**得られるのは名前空間だけ**である。問題は、**名前が果たせない約束をする**こと。
Expand Down
10 changes: 7 additions & 3 deletions src/enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type Invalid,
type Passes,
type Patch,
type Synchronous,
type Takes,
type Wired,
} from "./val.ts";
Expand Down Expand Up @@ -302,9 +303,12 @@ type VariantSealImpl<E extends AnyEnum, N extends keyof VariantsOf<E>, 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<G extends (...args: never[]) => unknown> = [string] extends [Parameters<G>[0]]
? Invalid<"a seal takes the payload, not a wire format; decode before sealing">
: G;
type CheckedSeal<G extends (...args: never[]) => unknown> = Synchronous<
G,
[string] extends [Parameters<G>[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
Expand Down
21 changes: 16 additions & 5 deletions src/val.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,11 +404,22 @@ type SealImpl<V extends AnyVal> = (value: SeedOf<V>, seal: (value: SeedOf<V>) =>
* `Record<string, unknown>` alone. A string payload cannot be told apart from a wire format, so
* it is exempt.
*/
type CheckedSeal<V extends AnyVal, G extends SealImpl<V>> = [string] extends [SeedOf<V>]
? G
: [string] extends [Parameters<G>[0]]
? Invalid<"a seal takes the payload, not a wire format; decode before sealing">
: G;
type CheckedSeal<V extends AnyVal, G extends SealImpl<V>> = Synchronous<
G,
[string] extends [SeedOf<V>]
? G
: [string] extends [Parameters<G>[0]]
? Invalid<"a seal takes the payload, not a wire format; decode before sealing">
: G
>;

export type Synchronous<G extends (...args: never[]) => unknown, Checked> =
// `any` is exempt. It matches a promise as it matches everything, so it shows nothing.
0 extends 1 & ReturnType<G>
? Checked
: [Extract<ReturnType<G>, PromiseLike<unknown>>] 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
Expand Down
9 changes: 9 additions & 0 deletions tests/enum.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Money>().implSeal(async (p, seal) => seal(p));
Enum.companion<Money>().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<Shape>()
.implVariant("Circle", (variant) =>
Expand Down
31 changes: 18 additions & 13 deletions tests/val.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Reading>().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<Reading>().implSeal((r: { n: number }, seal) => {
Expand Down Expand Up @@ -1615,6 +1602,24 @@ describe("building", () => {
expect(Email.seal("[email protected]")).toBe("[email protected]");
});
});

describe("the seal's return", () => {
test("a return that can be awaited is rejected", () => {
// @ts-expect-error a seal is synchronous
Val.companion<Age>().implSeal(async (n, seal) => seal(n));
// @ts-expect-error a string payload is exempt from the parameter check alone
Val.companion<Val<"Email", string>>().implSeal(async (e, seal) => seal(e));
// @ts-expect-error a union that includes a promise is the same
Val.companion<Age>().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<Age>().implSeal((n, seal) => Promise.resolve(seal(n)) as PromiseLike<Age>);
});

test("a return typed `any` is accepted", () => {
// oxlint-disable-next-line no-explicit-any -- the case under test
Val.companion<Age>().implSeal((n, seal): any => seal(n));
});
});
});

describe("implCreate", () => {
Expand Down
Loading