One contract (DeepKey / DeepValue / DeepKeyFiltered / ArrayOfChanges / BoolLogic /
MathExpr over a large state object), six implementation approaches, measured from 4.7k to 96k
paths under two TypeScript engines. The question throughout: what keeps the type-checker (and
the editor) cheap as the state grows past ~5–6k paths, where the union approach OOMs.
Path convention: [*] = Record/index key, [n] = array element, .?? = any/never sentinel.
const mk = makeChange<State>();
mk("users.[n].name", "Ada", extras); // ✏️ string path, FULL autocomplete
const changes: ArrayOfChanges<State, Extras> = [ // type-annotated array literal
["users.[n].name", "Ada", extras],
["settings.[*].enabled", true], // extra is optional
];
const cfg: BoolLogic<State> = { // condition DSL, literal
AND: [["users.[n].active", true], { EXISTS: "org.id" }, { GT: ["cart.length", 3] }],
};
const expr: MathExprUnion<State> = { SUB: [{ MUL: ["price", "qty"] }, "discount"] };Autocomplete: ✅ full · Path style: dotted string · Build step: none · At scale: ❌ OOM / TS2590 / TS2589
const mk = makeChange<State>();
mk("users.[n].name", "Ada", extras); // ✏️ same call — but NO autocomplete
const changes = arrayOfChanges<State>()([ // per-element builder
["users.[n].name", "Ada", extras],
["settings.[*].enabled", true],
]);Autocomplete: ❌ (you type the string blind; it's validated) · Path style: dotted string · Build: none · At scale: ✅ flat
const list = changeList<State>();
list.push("users.[n].name", "Ada", extras); // each push validated independently
list.push("cart.[n].qty", 2);
list.items; // : Changes<State> (flat, branded)Autocomplete: ❌ · Path style: dotted string · Build: none · At scale: ✅ flat (cheapest string form)
const s = sign<State>();
const mk = makeChange<State>();
mk(s("users.[n].name"), "Ada", extras); // sign() validates, mk trusts the brand
const p: FilteredPath<State, boolean> = filteredPath<State, boolean>()("users.[n].active");
// ^ opaque "a boolean path of State" — threads through generic <DATA> components for freeAutocomplete: ❌ · Path style: dotted string (token) · Build: none · At scale: ✅ flat
const p = path<State>();
change(p.users.$index.name, "Ada", extras); // ✏️ property access — FULL autocomplete
changes([[p.cart.$index.qty, 2], [p.settings.$any.enabled, true]]);
// $index → [n], $any → [*]; dotted path reconstructed at runtimeAutocomplete: ✅ full (real property access) · Path style: node refs (no strings) · Build: none · At scale: ✅ cheapest
const mk = makeChange(); // bound to State via generated.ts
mk("users.[n].name", "Ada", extras); // ✏️ string path, FULL autocompleteAutocomplete: ✅ full · Path style: dotted string · Build: regen on schema change · At scale:
// BoolLogic — naive union literal (explodes) // BoolLogic — builder (flat)
const cfg: BoolLogic<State> = { const b = boolLogic<State>();
AND: [ const cfg = b.and(
["users.[n].active", true], b.isEqual("users.[n].active", true),
{ EXISTS: "org.id" }, b.exists("org.id"),
{ GT: ["cart.length", 3] }, b.gt("cart.length", 3),
{ NOT: ["org.locked", true] }, b.not(b.isEqual("org.locked", true)),
], );
};
// MathExpr — naive union literal (explodes) // MathExpr — builder (flat)
const e: MathExprUnion<State> = const m = mathExpr<State>();
{ SUB: [{ MUL: ["price", "qty"] }, "discount"] }; const e = m.sub(m.mul(m.ref("price"), m.ref("qty")), m.ref("discount"));The builder output (BoolExpr<State> / MathExpr<State>) is a flat type, branded with the
state — so it nests arbitrarily deep, never forms a union, and a Pick<State> config flows into a
State slot soundly (see variance below).
| approach | what you type | autocomplete | string path | build step | extends by |
|---|---|---|---|---|---|
| naive | mk("a.b", v) / literal |
✅ full | ✅ | none | — (don't) |
| validated | mk("a.b", v) |
❌ | ✅ | none | one validator/op |
| vpush | list.push("a.b", v) |
❌ | ✅ | none | one op |
| branded | mk(sign("a.b"), v) |
❌ | ✅ token | none | one validator |
| proxy | change(p.a.b, v) |
✅ full | ❌ nodes | none | one accessor |
| codegen | mk("a.b", v) |
✅ full | ✅ | regen | regen |
Adding a DSL operator (e.g. BETWEEN, MATCHES) is O(1) and independent of state size: one
combinator method + one flat arm + (if needed) one path predicate. The union forms add a whole
path-union per operator.
seg("arr", idx(2), "val2") // TYPE: "arr.[n].val2" · RUNTIME: "arr.2.val2"
seg("obj", hk("dark"), "nested") // TYPE: "obj.[*].nested" · RUNTIME: "obj.dark.nested"
mk(seg("arr", idx(2), "val2"), false, e); // composes into any string builder
b.isEqual(seg("obj", hk(orgId), "nested", "inside"), true);idx(n) / hk(k) are the structural wildcards ("[n]" / "[*]") at the type level — so
Valid/DeepValue/DeepKey resolve exactly as the hand-written marker — and the concrete
index/key at runtime. A plain template literal can't do this (TS widens `a.${x}.b` to
string, losing validation), so seg() joins the typed parts and keeps the precise literal.
A listener reacts to changes within a scope (a substate). The natural tmp-valtio shape works
as-is once scoped — the explosion was only their SUB_STATE = any default (which makes the array
ArrayOfChanges<WholeState>).
// A — direct annotation (exactly tmp-valtio); correlated reads, small union because Scope is small
const fn = (changes: ArrayOfChanges<Scope>, state: Scope): ArrayOfChanges<Scope> | void => …;
// B — annotate with the listener type; params inferred
const fn: OnStateListener<DATA, Scope> = (changes, state) => …;
// C — flat variant for a whole-state / huge scope (never explodes; loose `[string, unknown]` reads)
const fn: FlatListener<DATA, DATA> = (changes, state) => changeList<DATA>().…items;
// D — scoped builder, change-builder BAKED IN: `emit` is a scoped validated push; the return is
// collected for you, so the body returns nothing.
const lst = defineListener<DATA>()("user.profile", (changes, state, emit) => {
emit("name", "Bob"); // state : DeepValue<DATA,"user.profile">, relative path, validated
});
// E — input-type-driven: the listener declares its SCOPE (input → output type); the path lives in a
// static { p, fn } object. `register` validates the path resolves to exactly that scope.
const ourListener = listener<Profile>((changes, state, emit) => emit("name", "Bob"));
const reg = register<DATA>()({ p: "user.profile", fn: ourListener }); // ✗ if path's value type ≠ Profile| reads | explodes when | use for | |
|---|---|---|---|
A / B ArrayOfChanges<Scope> (union) |
correlated ["val2", boolean] |
scope is the whole huge state | normal scoped listeners |
C Changes<Scope> (flat) |
loose [string, unknown] |
never | whole-state / null-scope listeners |
| D builder | flat (loose) | never | want the path validated + scope auto-derived |
The rule: scope it, and the tmp-valtio form is fine; only a genuinely whole-state listener needs the flat array type. Validation is per-listener (one anchor walk) → K listeners cost O(K).
| type | meaning | cost shape |
|---|---|---|
DeepValue<T, P> |
value type at ONE path | cheap — one walk; shared by every approach; never explodes |
DeepKey<T> |
union of every path | the union; cheap to reference, explodes when mapped/filtered/checked-against |
DeepKeyFiltered<T, F> |
paths whose value extends F |
maps over DeepKey → explodes |
ArrayOfChanges<T, E> |
[path, value, extra?][] |
mapped+indexed over the union → worst array case |
BoolLogic<T> |
recursive condition DSL | recursive union, every arm a path union → worst overall |
MathExpr<T> |
recursive numeric DSL | BoolLogic's twin, leaves are DeepKeyFiltered<T, number> |
All runs: isolated tsc/tsgo program per workload, /usr/bin/time -l for real peak RSS. The
generated BigState = a fixed anchor (booleans/string/number/arrays/records/5-deep + any/never/
unknown) merged with a scalable bulk. ✓ = clean; TS2589 = "excessively deep / too many
instantiations"; TS2590 = "union too complex to represent".
| approach | string | autocomplete | RAM @ 6k | RAM @ 48k | build | call-site rewrite to adopt |
|---|---|---|---|---|---|---|
| naive | ✅ | full | ❌ OOM | ❌ TS2589 | none | none |
| validated | ✅ | ❌ | ✅ flat | ✅ flat | none | none (drop annotations) |
| vpush | ✅ | ❌ | ✅ flat | ✅ flat | none | small (array → push) |
| branded | ✅ | ❌ | ✅ flat | ✅ flat | none | small (wrap in sign) |
| proxy | ❌ | full | ✅ flat | ✅ flat | none | large (strings → p.a.b) |
| codegen | ✅ | full | ✅ flat* | regen | none |
* membership only; DeepKeyFiltered/ArrayOfChanges/BoolLogic over the frozen union still explode.
| approach | form | wall | peak RSS | result |
|---|---|---|---|---|
| proxy | node-ref tuples | 0.8s | 284MB | ✓ |
| vpush | per-call accumulator → flat Change[] |
1.7s | 219MB | ✓ |
| branded | per-call sign() |
1.7s | 231MB | ✓ |
| validated | arrayOfChanges mapped-tuple builder |
7.4s | 2,629MB | ✓ |
| naive | ArrayOfChanges<…> annotation (prod form) |
21.5s | 276MB | TS2590 |
| codegen | per-element P & BigKeys intersection |
31.3s | 2,719MB | ✓ |
Even the builder can be made to explode (validated/codegen here): a single
<const>array literal of 5,400 elements creates a giant mapped-tuple/huge-union-intersection. The cheap forms validate each element independently (vpush/proxy/branded) — nothing pools.
| approach | @ 4.7k | @ 19k | trend |
|---|---|---|---|
| proxy | 0.33s / 190MB | 0.36s / 206MB | flat |
| vpush | 0.35s / 192MB | 0.39s / 208MB | flat |
| branded | 0.36s / 196MB | 0.38s / 209MB | flat |
| validated | 0.38s / 203MB | 0.41s / 211MB | flat |
| codegen | 1.39s / 476MB | 5.84s / 1,297MB | climbing |
| naive | 1.26s / 526MB | 5.35s / 1,494MB | climbing |
| approach | N≈19k | N≈48k | N≈96k |
|---|---|---|---|
| validated | ✓ 0.4s / 207MB | ✓ 0.5s / 237MB | ✓ 0.5s / 274MB |
| vpush | ✓ 0.4s / 207MB | ✓ 0.5s / 233MB | ✓ 0.5s / 273MB |
| branded | ✓ 0.4s / 210MB | ✓ 0.5s / 237MB | ✓ 0.5s / 273MB |
| proxy | ✓ 0.4s / 205MB | ✓ 0.5s / 229MB | ✓ 0.5s / 268MB |
| codegen | ✓ 1.6s / 544MB | TS2589 | TS2589 |
| naive | ✓ 3.5s / 805MB | TS2589 | TS2589 |
| workload | N≈4.8k | N≈9.6k | N≈19k | N≈48k |
|---|---|---|---|---|
| bool-union | ✓ 1.6s / 496MB | ✓ 3.4s / 877MB | ✓ 5.9s / 1,329MB | TS2589 / 1,778MB |
| bool-builder | ✓ 0.4s / 195MB | ✓ 0.4s / 200MB | ✓ 0.4s / 208MB | ✓ 0.5s / 231MB |
| math-union | ✓ 0.8s / 249MB | ✓ 1.3s / 301MB | ✓ 2.2s / 602MB | TS2589 / 834MB |
| math-builder | ✓ 0.4s / 194MB | ✓ 0.5s / 200MB | ✓ 0.4s / 209MB | ✓ 0.5s / 232MB |
Union forms double RAM every step (496 → 877 → 1,329 → 1,778MB) — superlinear, straight into TS2589 by ~48k. Builders don't move.
| concern | N≈4.8k | N≈46k |
|---|---|---|
| DeepValue · shared (concrete paths) | 0.4s / 186MB | 0.4s / 225MB ✓ |
| DeepKey · union (bare reference) | 0.6s / 201MB | 1.3s / 381MB ✓ |
| ArrayOfChanges · union | 1.0s / 277MB | TS2589 / 897MB |
| ArrayOfChanges · vpush | 0.4s / 194MB | 0.5s / 230MB ✓ |
| ArrayOfChanges · proxy | 0.4s / 189MB | 0.5s / 225MB ✓ |
DeepValue(per-path) is unconditionally flat. A bareDeepKeyreference survives — the explosion comes from operating on it (ArrayOfChanges/DeepKeyFiltered/BoolLogic).
| workload | tsc 5.9 | tsgo 7.0 | tsgo speedup |
|---|---|---|---|
| aoc-union | 2.6s / 790MB | 1.2s / 389MB | ~2× |
| aoc-vpush | 0.41s / 206MB | 0.11s / 87MB | ~4× |
| bool-union | 6.0s / 1,470MB | 1.8s / 663MB | ~3× |
| bool-builder | 0.43s / 206MB | 0.12s / 88MB | ~4× |
| math-union | 2.1s / 263MB | 0.7s / 191MB | ~3× |
| math-builder | 0.43s / 209MB | 0.11s / 88MB | ~4× |
tsgo is ~3–4× faster and ~½ RAM across the board — but it raises the ceiling by a constant, not the asymptote. Union forms are still superlinear under tsgo (663MB / 1.8s for one config at 19k), which is why your real code still OOM'd it at 20GB. Builders are effectively free under tsgo (0.11s / 88MB). LSP hover renders union aliases by name (cheap to show); diagnostics recompute on every edit at the batch cost — imperceptible for builders, a visible stutter for union forms.
| approach | cost grows with | binding limit | practical ceiling |
|---|---|---|---|
| validated / vpush / branded | uses × depth, NOT N (parse is the only N cost) | state-file AST memory | ~10M leaves (~8 GB parse); tested ✓ to 767k |
| proxy | N (lazy object tree) | instantiation count ~5M | very large (sparse access is O(touched)) |
| codegen | N (flat frozen union) | ~100k union, build must finish | ~20–48k (ops over it explode sooner) |
naive DeepKey |
N union, O(N²) reduce | ~100k (TS2590) | ~20–48k single concern |
naive ArrayOfChanges |
N correlated tuples | TS2589 / TS2590 | ~2k single literal |
BoolLogic / MathExpr union |
(#operators × N), recursive | TS2589 (~5M instantiations) | ~20–48k single config; few-k with many |
The empirical wall is TS2589 (instantiation count ~5M), hit around 20–48k — before the ~100k union-size limit (TS2590). It arrives sooner with realistic workloads (multiple configs, filters, big arrays), which is why a real 5–6k state already dies.
| workload | N≈96k | N≈192k | N≈384k | N≈767k |
|---|---|---|---|---|
| flat (vpush / deepvalue / bool-builder) | ✓ 0.2s / 130MB | ✓ 0.2s / 187MB | ✓ 0.4s / 294MB | ✓ 0.7s / 501MB |
| union (aoc-union) | TS2590 0.9s | TS2589 1.5s | TS2589 1.6s | TS2589 1.8s |
Flat has no type-system ceiling. Clean to 767k leaves (~150× a real state); RSS grows
dead-linear (~0.55 MB / 1k leaves) purely from parsing the state file, type-check stays sub-second.
Extrapolated, the wall is the state's AST exhausting memory — ~7M leaves @ 4 GB, ~15M @ 8 GB.
Union fails fast (1.5–1.8s, bails at the instantiation/union limit) from ~48k up. Net: a
~200–500× gap between the flat ceiling (~10M leaves, a parsing limit) and the union ceiling
(~20–48k, a type-system limit). bench/max-scale.ts reproduces this.
Changes<D, E>, BoolExpr<D>, and MathExpr<D> are each branded with D in a contravariant
position — { readonly [BRAND]?: (d: D) => void }. So Changes<Pick<FULL,'k'>> is assignable to
Changes<FULL> (every subset path exists in FULL), while the unsound reverse is rejected — and the
brand is phantom, so it costs nothing:
const sub = changeList<Pick<FULL, "cart">>(); sub.push("cart.[n].qty", 2);
const full: Changes<FULL> = sub.items; // ✅ subset → full, no union, no cast
acceptsBoolExprFull(boolLogic<Pick<FULL,"cart">>().exists("cart.[n].id")); // ✅ same for DSLs| You need… | Use | Why |
|---|---|---|
| Build a changes array, keep dotted strings | vpush (changeList) |
flat (219MB/1.7s; 0.11s under tsgo), validates on insert |
| …no strings OK | proxy | cheapest + full autocomplete (lazy node tree) |
| Pass a filtered path through generic components | branded FilteredPath |
never materializes; threads <DATA extends object> for free |
| Build a condition / math DSL | boolLogic / mathExpr builders |
flat Expr<D>, nests deep, composes across subsets |
| Write a state-change listener | defineListener / OnStateListener (scoped) |
fn sees only its substate; flat Changes<Scope> for whole-state |
Stop hand-typing [n] / [*] |
seg + idx / hk |
typed wildcard at compile time, concrete index/key at runtime |
| Dotted strings with autocomplete | codegen | flat membership; pay once at build; avoid its array/filter ops |
| ❌ Avoid | naive ArrayOfChanges / DeepKeyFiltered / BoolLogic / MathExpr annotation |
the mapped-over-union form — this is the OOM |
Holding the whole path set correlated at once explodes — as a union (DeepKey,
DeepKeyFiltered, naive DSLs → TS2590/TS2589), a mapped tuple over N elements (arrayOfChanges at
full scale → multi-GB), or a huge-union intersection (P & BigKeys → worst). Per-path validation
stays linear — each path is checked and discarded independently, so nothing pools. That's the
entire difference between the OOM and a flat 0.4s / 200MB. It's decoupled from state size: validating
"a.b.c" costs O(depth) whether the state has 10 leaves or 1,000,000. tsgo makes everything ~3–4×
faster but doesn't change the asymptote — the builders are the fix under every engine.
Everything below is the complete, working source. Path convention: [*] = Record/index key,
[n] = array element, .?? = any/never sentinel. The metadata type is yours to define:
// fixture.ts — your state + change metadata
export type Extras = { sourceOfChange: string };DeepValue resolves ONE path. It's O(depth), never explodes, and every approach builds on it.
export type IsAny<T> = 0 extends 1 & T ? true : false;
export type IsNever<T> = [T] extends [never] ? true : false;
export type IsIndexRecord<T> = string extends keyof T ? true : false; // true only for index signatures
export type Prev = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]; // depth fuel
export type DeepValue<T, P extends string> =
IsAny<T> extends true ? any :
IsNever<T> extends true ? never :
P extends `${infer H}.${infer R}`
? H extends "[*]" ? IsIndexRecord<T> extends true ? (T extends Record<string, infer V> ? DeepValue<V, R> : never) : never
: H extends "[n]" ? T extends readonly (infer E)[] ? DeepValue<E, R> : never
: H extends keyof T ? DeepValue<T[H], R> : never
: P extends "[*]" ? IsIndexRecord<T> extends true ? (T extends Record<string, infer V> ? V : never) : never
: P extends "[n]" ? T extends readonly (infer E)[] ? E : never
: P extends keyof T ? T[P] : never;import type { IsAny, IsNever, IsIndexRecord, DeepValue } from "./core";
import type { Extras } from "./fixture";
// Walk ONE path segment by segment. Returns true/false; never builds a union.
export type IsValidPath<T, S extends string> =
IsAny<T> extends true ? (S extends "??" ? true : false) :
IsNever<T> extends true ? (S extends "??" ? true : false) :
S extends `${infer H}.${infer R}`
? H extends "[*]" ? IsIndexRecord<T> extends true ? (T extends Record<string, infer V> ? IsValidPath<V, R> : false) : false
: H extends "[n]" ? T extends readonly (infer E)[] ? IsValidPath<E, R> : false
: H extends keyof T ? IsValidPath<T[H], R> : false
: S extends "[*]" ? IsIndexRecord<T>
: S extends "[n]" ? (T extends readonly any[] ? true : false)
: S extends keyof T ? IsAny<T[S]> extends true ? false : IsNever<T[S]> extends true ? false : true
: false;
// Valid<T,S> = S if the path is valid, else never. `P & Valid<T,P>` keeps P inferable while
// rejecting bad literals (never -> the literal isn't assignable -> a type error at the call).
export type Valid<T, S extends string> = IsValidPath<T, S> extends true ? S : never;
export const makeChange =
<T>() =>
<P extends string>(path: P & Valid<T, P>, value: DeepValue<T, P>, extras: Extras) =>
[path, value, extras] as const;
// "filtered" = valid AND value extends F (single walk, no DeepKeyFiltered union).
export type ValidFiltered<T, F, S extends string> =
Valid<T, S> extends never ? never : DeepValue<T, S> extends F ? S : never;
// --- the changes array, branded with DATA in a CONTRAVARIANT position (sound subset -> full) ---
export type LooseChange = readonly [string, unknown, Extras?];
declare const DATA: unique symbol;
export type Changes<D, E = Extras> = ReadonlyArray<readonly [string, unknown, E?]> & {
readonly [DATA]?: (d: D) => void;
};
// vpush — the cheap accumulator. Each push validates one path↔value; items is a flat Changes<T>.
export const changeList = <T>() => {
const items: LooseChange[] = [];
const push = <P extends string>(path: P & Valid<T, P>, value: DeepValue<T, P>, extras?: Extras): void => {
items.push([path, value, extras]);
};
const list: Changes<T> = items;
return { push, items: list };
};
// Per-element array builder (validates each element; heavier than changeList at full scale).
type Validate<T, Items extends readonly unknown[]> = {
readonly [I in keyof Items]: Items[I] extends readonly [infer P extends string, ...unknown[]]
? readonly [P & Valid<T, P>, DeepValue<T, P>, Extras?] : never;
};
export const arrayOfChanges =
<T>() =>
<const Items extends readonly (readonly [string, ...unknown[]])[]>(items: Items & Validate<T, Items>): Items => items;import type { DeepValue } from "./core";
import type { Valid, ValidFiltered } from "./validated";
import type { Extras } from "./fixture";
declare const BRAND: unique symbol;
export type Signed<D, P extends string> = { readonly path: P; readonly [BRAND]: D };
export const sign =
<D>() =>
<P extends string>(path: P & Valid<D, P>): Signed<D, P> => {
const p: P = path; // intersection ⊆ P, no `as unknown`
return { path: p } as Signed<D, P>; // phantom brand
};
export const makeChange =
<D>() =>
<P extends string>(signed: Signed<D, P>, value: DeepValue<D, P>, extras: Extras) =>
[signed.path, value, extras] as const;
// Opaque "an F-valued path of D" — `string & brand`, NEVER the DeepKeyFiltered union, so it threads
// through generic <DATA> components and instantiates at a huge concrete DATA at zero cost.
declare const FILTER: unique symbol;
export type FilteredPath<D, F> = string & { readonly [FILTER]: readonly [D, F] };
export type FilterOf<T> = T extends FilteredPath<infer _D, infer F> ? F : never;
export const filteredPath =
<D, F>() =>
<P extends string>(path: P & ValidFiltered<D, F, P>): FilteredPath<D, F> => {
const p: string = path;
return p as FilteredPath<D, F>; // phantom brand (string -> branded string)
};import type { IsAny, IsNever, IsIndexRecord } from "./core";
import type { Extras } from "./fixture";
declare const VALUE: unique symbol;
const PATH: unique symbol = Symbol("path"); // shared by type + runtime -> no cast
type Leaf<V> = { readonly [VALUE]: V; readonly [PATH]: string };
export type ValueOf<N> = N extends Leaf<infer V> ? V : never;
// `[T] extends [...]` keeps these NON-distributive (boolean -> one Leaf<boolean>, not a union).
export type Proxy<T> =
IsAny<T> extends true ? Leaf<any>
: IsNever<T> extends true ? Leaf<never>
: [T] extends [readonly (infer E)[]] ? { readonly $index: Proxy<E> } & Leaf<T>
: IsIndexRecord<T> extends true ? (T extends Record<string, infer V> ? { readonly $any: Proxy<V> } & Leaf<T> : Leaf<T>)
: [T] extends [object] ? { readonly [K in keyof T]-?: Proxy<T[K]> } & Leaf<T>
: Leaf<T>;
const makeProxy = (segments: readonly string[]): any =>
new Proxy({ [PATH]: segments.join(".") }, {
get(target, prop) {
if (prop === PATH) return target[PATH];
const seg = prop === "$index" ? "[n]" : prop === "$any" ? "[*]" : String(prop);
return makeProxy([...segments, seg]);
},
});
export const path = <T>(): Proxy<T> => makeProxy([]);
export const change = <V>(node: Leaf<V>, value: V, extras: Extras) => [node[PATH], value, extras] as const;import type { IsAny, IsNever, Prev, DeepValue } from "./core";
export type DeepKey<T, D extends number = 16> =
[D] extends [never] ? never :
IsAny<T> extends true ? "??" : IsNever<T> extends true ? "??" :
T extends readonly (infer E)[] ? "[n]" | (DeepKey<E, Prev[D]> extends infer R extends string ? `[n].${R}` : never)
: T extends object
? string extends keyof T
? T extends Record<string, infer V> ? "[*]" | (DeepKey<V, Prev[D]> extends infer R extends string ? `[*].${R}` : never) : never
: { [K in keyof T & string]:
IsAny<T[K]> extends true ? `${K}.??` : IsNever<T[K]> extends true ? `${K}.??`
: K | (DeepKey<T[K], Prev[D]> extends infer R extends string ? `${K}.${R}` : never);
}[keyof T & string]
: never;
export type DeepKeyFiltered<T, F, K extends string = DeepKey<T>> =
K extends K ? IsAny<DeepValue<T, K>> extends true ? never : IsNever<DeepValue<T, K>> extends true ? never
: DeepValue<T, K> extends F ? K : never : never;
// The production form that OOMs: a mapped type over the whole DeepKey union, indexed by it again.
export type ArrayOfChanges<T, E> = { [K in DeepKey<T>]: readonly [K, DeepValue<T, K>, E?] }[DeepKey<T>][];Used by Changes<D,E>, BoolExpr<D>, MathExpr<D>. Put D in a function-parameter position:
declare const BRAND: unique symbol;
type Branded<Shape, D> = Shape & { readonly [BRAND]?: (d: D) => void };
// Branded<_, SUBSET> assignable to Branded<_, FULL> ⟺ (d:SUBSET)=>void <: (d:FULL)=>void
// ⟺ FULL <: SUBSET (params are contravariant)
// ⟺ true for SUBSET = Pick<FULL, …>
// → subset→full allowed, full→subset rejected, phantom brand = zero cost (no union).import type { DeepValue } from "./core";
import type { Valid } from "./validated";
type BoolShape =
| { readonly IS_EQUAL: readonly [string, unknown] } | { readonly EXISTS: string } | { readonly IS_EMPTY: string }
| { readonly AND: readonly BoolShape[] } | { readonly OR: readonly BoolShape[] } | { readonly NOT: BoolShape }
| { readonly GT: readonly [string, number] } | { readonly LT: readonly [string, number] }
| { readonly IN: readonly [string, readonly unknown[]] };
declare const STATE: unique symbol;
export type BoolExpr<D = unknown> = BoolShape & { readonly [STATE]?: (d: D) => void }; // flat + contravariant brand
// GT/LT paths: numeric-valued, or `${arrayPath}.length`.
export type ValidNumeric<T, P extends string> =
P extends `${infer Base}.length`
? Valid<T, Base> extends never ? never : DeepValue<T, Base> extends readonly unknown[] ? P : never
: Valid<T, P> extends never ? never : DeepValue<T, P> extends number ? P : never;
export const boolLogic = <T>() => ({
isEqual: <P extends string>(path: P & Valid<T, P>, value: DeepValue<T, P>): BoolExpr<T> => ({ IS_EQUAL: [path, value] }),
exists: <P extends string>(path: P & Valid<T, P>): BoolExpr<T> => ({ EXISTS: path }),
isEmpty: <P extends string>(path: P & Valid<T, P>): BoolExpr<T> => ({ IS_EMPTY: path }),
and: (...e: BoolExpr<T>[]): BoolExpr<T> => ({ AND: e }),
or: (...e: BoolExpr<T>[]): BoolExpr<T> => ({ OR: e }),
not: (e: BoolExpr<T>): BoolExpr<T> => ({ NOT: e }),
gt: <P extends string>(path: P & ValidNumeric<T, P>, n: number): BoolExpr<T> => ({ GT: [path, n] }),
lt: <P extends string>(path: P & ValidNumeric<T, P>, n: number): BoolExpr<T> => ({ LT: [path, n] }),
in: <P extends string>(path: P & Valid<T, P>, values: DeepValue<T, P>[]): BoolExpr<T> => ({ IN: [path, values] }),
});mathexpr.ts is identical in shape — MathShape (number | { REF: string } | { ADD: MathShape[] } | …),
MathExpr<D> = MathShape & { [STATE]?: (d: D) => void }, and mathExpr<T>() with
ref: <P>(p: P & ValidFiltered<T, number, P>) => ({ REF: p }) plus add/sub/mul/div/….
export const idx = (n: number): "[n]" => `${n}` as "[n]"; // type "[n]", runtime the concrete index
export const hk = (key: string): "[*]" => key as "[*]"; // type "[*]", runtime the concrete key
type Join<T extends readonly string[]> =
T extends readonly [infer H extends string, ...infer R extends string[]]
? R extends readonly [] ? H : `${H}.${Join<R>}` : "";
export const seg = <const T extends readonly string[]>(...parts: T): Join<T> => parts.join(".") as Join<T>;
// seg("arr", idx(2), "val2") → type "arr.[n].val2", runtime "arr.2.val2" (template literals widen; seg doesn't)import type { ArrayOfChanges } from "./naive"; // union: correlated reads, cheap when scoped
import type { DeepValue } from "./core";
import type { Valid, Changes } from "./validated"; // flat: any scope size, loose reads
import type { Extras } from "./fixture";
export type Scope<DATA, P extends string> = DeepValue<DATA, P>;
export type OnStateListener<DATA, State = DATA, META extends Extras = Extras> =
(changes: ArrayOfChanges<State, META>, state: State) => ArrayOfChanges<State, META> | void;
export type FlatListener<DATA, State = DATA, META = Extras> =
(changes: Changes<State, META>, state: State) => Changes<State, META> | void;
// Baked-in builder: the handler gets `emit` (a scoped, validated push) and returns nothing;
// defineListener collects the emits into a Changes<Scope> and builds the engine-facing fn.
import { changeList } from "./validated";
export type Emit<S> = <P extends string>(path: P & Valid<S, P>, value: DeepValue<S, P>, extras?: Extras) => void;
export type Handler<DATA, P extends string> =
(changes: Changes<Scope<DATA, P>>, state: Scope<DATA, P>, emit: Emit<Scope<DATA, P>>) => void;
export const defineListener =
<DATA>() =>
<P extends string>(path: P & Valid<DATA, P>, handler: Handler<DATA, P>) => {
const fn = (changes: Changes<Scope<DATA, P>>, state: Scope<DATA, P>): Changes<Scope<DATA, P>> => {
const out = changeList<Scope<DATA, P>>();
handler(changes, state, out.push); // emit === scoped changeList push
return out.items; // ← return type baked in
};
return { path, fn } as const;
};
// Input-type-driven: define the listener by its SCOPE (input → output), attach the path separately.
export type ScopedFn<S> = (changes: Changes<S>, state: S) => Changes<S>;
export const listener =
<S>(handler: (changes: Changes<S>, state: S, emit: Emit<S>) => void): ScopedFn<S> =>
(changes, state) => {
const out = changeList<S>();
handler(changes, state, out.push);
return out.items;
};
// `p` must be a valid path of DATA AND resolve to the listener's scope — both checked on the literal.
export const register =
<DATA>() =>
<P extends string>(reg: { p: P & Valid<DATA, P>; fn: ScopedFn<Scope<DATA, P>> }) => reg;The generator builds a BigState (fixed anchor with every tricky kind + a scalable random bulk),
emits an isolated tsc/tsgo program per workload, and a ground-truth oracle it cross-checks
against the real DeepKey. All numbers in Part 3 come from these:
bun install
bunx vitest run --typecheck # the equivalence + behavior tests (all approaches)
# generate the workloads; --leaves sets the path count, --freeze 0 skips the naive freeze (needed >10k)
bun bench/gen.ts --leaves 5400 # knobs: --depth --width --changes --huge --subset --aoc --freeze --seed
bun bench/run.ts --repeat 2 # Part 3 tables B/C: DeepKey-program + ArrayOfChanges + Static suites
bun bench/scale.ts # Table D: DeepKey program at 20k/50k/100k (where union forms fall over)
bun bench/dsl-scale.ts # Table E: BoolLogic/MathExpr union vs builder, 5k→50k
bun bench/derived-scale.ts # Table F: DeepKey/DeepValue/ArrayOfChanges union vs builder at 48k
bun bench/max-scale.ts # Part 3 maximums: flat pushed to 800k under tsgo vs union's ~48k wallEach workload is one tsconfig per impl under bench/_generated/ (and bench/static|dsl|derived/),
type-checked in isolation and wrapped in /usr/bin/time -l for real peak RSS. Swap engines by
running tsgo instead of tsc (bun add -D @typescript/native-preview). To rescale a static
workload, regenerate only bench/_generated/state.ts — the hand-written files re-run unchanged.