Skip to content

Instantly share code, notes, and snippets.

@sladg
Created June 25, 2026 10:24
Show Gist options
  • Select an option

  • Save sladg/0b0cd88e037137e36144427c264ae50a to your computer and use it in GitHub Desktop.

Select an option

Save sladg/0b0cd88e037137e36144427c264ae50a to your computer and use it in GitHub Desktop.
Deep-path TypeScript types at scale — ergonomics & benchmarks

Deep-path types at scale — ergonomics & benchmarks

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.


Part 1 — Ergonomics: what you actually write

naive — generator union (the baseline that explodes)

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

validated — single-path walk (no union)

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

vpush — per-call accumulator (the recommended changes builder)

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)

branded — validate once, pass the token

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 free

Autocomplete: ❌ · Path style: dotted string (token) · Build: none · At scale: ✅ flat

proxy — accessor tree (no strings, full autocomplete)

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 runtime

Autocomplete: ✅ full (real property access) · Path style: node refs (no strings) · Build: none · At scale: ✅ cheapest

codegen — frozen flat union (build step)

const mk = makeChange();                                   // bound to State via generated.ts
mk("users.[n].name", "Ada", extras);                       // ✏️ string path, FULL autocomplete

Autocomplete: ✅ full · Path style: dotted string · Build: regen on schema change · At scale: ⚠️ flat for membership, but filtering/array ops still explode

The DSLs — same config, union vs builder

// 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).

Ergonomics summary

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.

Path segments — idx / hk / seg (don't hand-type the markers)

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.

Listeners — the tmp-valtio OnStateListener use case, four ways

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).


Part 2 — The contract (derived types)

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>

Part 3 — The data

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".

A. Approaches × properties

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* ⚠️ ops explode regen none

* membership only; DeepKeyFiltered/ArrayOfChanges/BoolLogic over the frozen union still explode.

B. ArrayOfChanges — one full-scale array (5,400 changes, classic tsc 5.9)

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.

C. Static suite — same files, BigState swapped (BoolLogic + changes + type refs)

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

D. Scaling — DeepKey program, where each falls over (classic tsc 5.9)

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

E. DSL scaling — BoolLogic / MathExpr, union vs builder (classic tsc 5.9)

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.

F. Derived types — union vs builder/per-path (classic tsc 5.9)

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 bare DeepKey reference survives — the explosion comes from operating on it (ArrayOfChanges/DeepKeyFiltered/BoolLogic).

G. Engines — tsc 5.9 (classic / "v6" line) vs tsgo 7.0 (native, the one that OOM'd you) @ 19k

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.

H. Theoretical limits — where the wall is

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–48kbefore 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.

Pushed to the limit (tsgo 7.0) — flat vs union

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.


Part 4 — Composition & recommendations

Subset → full assignability (no union, sound)

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

Recommendations

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

The one finding

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.


Part 5 — Reference implementations (copy-paste to reconstruct)

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 };

5.1 — core.ts (the foundation — DeepValue + primitives)

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;

5.2 — validated.ts (the recommended core: per-path validation, no union)

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;

5.3 — branded.ts (validate once, pass an opaque token; FilteredPath for props)

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)
  };

5.4 — proxy.ts (accessor tree, full autocomplete, no strings)

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;

5.5 — naive.ts (the union baseline — what NOT to ship; keep only as a reference/oracle)

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>][];

5.6 — the contravariant brand (the reusable subset→full trick)

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).

5.7 — boollogic.ts (condition DSL — combinator builder)

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/….

5.8 — segment.ts (typed [n] / [*] builders)

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)

5.9 — listener.ts (scoped listeners)

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;

Part 6 — Reproduce the benchmarks

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 wall

Each 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment