data-graph typescript optimizations

This commit is contained in:
Briant Diehl
2026-05-15 11:52:07 -06:00
parent 853b0653f2
commit 7f693392c4
2 changed files with 323 additions and 23 deletions
+233
View File
@@ -0,0 +1,233 @@
# Investigation: making `data-graph.ts` type inference more efficient
## Goal
Reduce the type-instantiation cost of `DataGraph` builder chains in [src/libs/data-graph/data-graph.ts](../../src/libs/data-graph/data-graph.ts), so that:
1. Existing graphs typecheck faster.
2. The depth budget for `discriminator + many sibling .node(...)` chains rises enough that future ecosystems can use the discriminator pattern without hitting `error TS2589: Type instantiation is excessively deep and possibly infinite`.
This is a **library-side** investigation. Per-graph workarounds (chunking via `merge`, factory-form nodes that branch at runtime) are explicitly out of scope here — they're escape hatches, not fixes.
## Constraints
- **No public API changes** to `DataGraph`. Only internal helper types and overload return types are fair game.
- **No regression** in any existing graph (Ernie, Seedance, Flux, Stable Diffusion, Wan, etc., all currently typecheck on `main` and stay that way).
- **No compiler flag changes.** No `// @ts-nocheck`, no patched `tsc`, no raised depth limits via internals.
- **Runtime behavior preserved.** All builder methods are `as any`-cast at runtime; only the static return types are being adjusted.
## Background — what's already known
`DataGraph` ([src/libs/data-graph/data-graph.ts](../../src/libs/data-graph/data-graph.ts), ~2070 lines) is the in-house typed graph builder used to define generation forms in [src/shared/data-graph/generation/](../../src/shared/data-graph/generation/). Each ecosystem graph is one `DataGraph<Ctx, ExternalCtx>` whose `.node(...)`, `.computed(...)`, `.discriminator(...)`, `.effect(...)`, and `.merge(...)` calls thread an evolving `Ctx` type through the builder chain.
All ecosystem graphs are then composed into one `groupedDiscriminator(...)` in [src/shared/data-graph/generation/ecosystem-graph.ts](../../src/shared/data-graph/generation/ecosystem-graph.ts). That outer composition is where the depth limit ultimately bites in failure cases.
### The pain pattern (already documented)
A graph with shape `discriminator(...)` + ~12 sibling `.node(...)` calls trips TS2589. The post-discriminator nodes each re-fold a 2-branch union, and the chain compounds. See the prior investigation doc [`data-graph-typescript-depth-limit.md`](./data-graph-typescript-depth-limit.md) for the failure trace. This investigation addresses the underlying cost rather than per-graph workarounds.
### Suspect helpers (from reading the source)
By name and location in [data-graph.ts](../../src/libs/data-graph/data-graph.ts):
| Helper | Line | Why it's a suspect |
| ------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| `Prettify<T>` | 11 | Mapped type `{[K in keyof T]: T[K]}` — re-evaluated on every `.node()`/`.computed()`/`.merge()` call. Purely cosmetic for hover display. |
| `MergeDistributive<A, B>` | 14 | Distributes over A and runs `Prettify` per branch. Called by every `.node()` and `.computed()`. |
| `MergePreferRight<A, B>` | 257 | Double-distributes (over both A and B) and runs `Prettify`. Used inside `BuildDiscriminatedUnion` and `BuildGroupedDiscriminatedUnion`. |
| `BuildDiscriminatedUnion` | 284 | Folds parent ctx into each branch via nested `MergePreferRight`. Re-evaluates `OmitDistributive<ParentCtx, DiscKey>` per branch instead of once. |
| `.node()` overloads | 943, 965, 996 | Each takes `<const K, const Deps, T extends AnyZodSchema, M>``const` modifier inference can interact badly with deep distribution. |
The codebase already opts out of `Prettify` in two places ([lines 341 and 353](../../src/libs/data-graph/data-graph.ts#L341)) "to reduce type evaluation overhead." Extending that policy is the lowest-risk lever.
## Plan
### Phase 1 — Measure against `generation-graph`
Real workload, no synthetic harness. The full repo typecheck exercises every ecosystem graph plus the outer `groupedDiscriminator`, which is exactly the cost picture we want.
**Baseline capture:**
```bash
pnpm tsc --noEmit --extendedDiagnostics --generateTrace .ts-trace
```
Record:
- Total check time
- Type instantiation count
- Top spans in the trace whose stack mentions `data-graph.ts`
Optional: a focused `tsconfig.measurement.json` that includes only `src/shared/data-graph/**` and its imports, so the trace is dominated by relevant work and easier to read in `edge://tracing`.
**What to look for:**
- Cumulative time spent in `Prettify` / `MergeDistributive` / `MergePreferRight` / `BuildDiscriminatedUnion` instantiations.
- Per-call cost of `.node()` chains in the heaviest ecosystems (Flux, Stable Diffusion, Wan).
- Whether `groupedDiscriminator` in `ecosystem-graph.ts` shows up as one big span or many smaller ones.
**Phase 1 output:** a single line — "dominant span is **, accounts for **% of check time, fires \_\_ times" — that the rest of the plan reacts to.
**Note on failure thresholds:** the repo currently typechecks on `main`, so Phase 1 captures the **cost picture** but not a **failure threshold**. That's enough to pick the right helper to optimize. If we later want a "moved the budget from N nodes to N+5" measurement, we'd need to separately construct a failure case (intentionally out of scope for this investigation unless requested).
### Phase 2 — Targeted fixes in priority order
These are independent. Apply only what Phase 1 evidence supports. Each is internal-only and preserves runtime behavior.
#### A. Strip `Prettify` from intermediate accumulator types
- Already precedent: [lines 341, 353](../../src/libs/data-graph/data-graph.ts#L341) deliberately skip `Prettify` "to reduce type evaluation overhead." Extend the same policy to `.node()`, `.computed()`, and `.merge()` return types ([lines 957, 985, 1016, 1137, 1158, 1257](../../src/libs/data-graph/data-graph.ts#L957)).
- `Prettify` is a mapped type re-evaluated _per chained call_. With a 2-branch union and a 12-step chain that's 24 mapped-type instantiations purely for display flattening.
- **Trade-off:** hover tooltips show `A & B & C` instead of a flattened `{ a, b, c }`. Acceptable; matches how the codebase already treats meta/values accumulators.
#### B. Make `MergePreferRight` non-distributive on the right side
- Current ([line 257](../../src/libs/data-graph/data-graph.ts#L257)):
```ts
type MergePreferRight<A, B> = A extends unknown
? B extends unknown
? Prettify<Omit<A, keyof B> & B>
: never
: never;
```
- In every internal callsite, `B` is a tiny non-union shape (e.g., `{[K in DiscKey]: BranchName}`). Distributing over `B` is wasted work.
- Switch the inner check to `[B] extends [unknown]` to disable B-side distribution. Keep A-side distribution because A is the discriminated-union accumulator.
- **Caveat:** audit the ~3 callsites (grep `MergePreferRight` in the file) before changing — verify none rely on B-side distribution. If any do, leave that callsite using a separate distributive variant.
#### C. Cache the parent-ctx omission across branches in `BuildDiscriminatedUnion`
- Current ([line 290](../../src/libs/data-graph/data-graph.ts#L290)) computes `OmitDistributive<ParentCtx, DiscKey>` _inside_ the mapped type, so it re-evaluates per branch.
- Hoist it to a named alias so it's instantiated once per discriminator instead of per branch:
```ts
type BuildDiscriminatedUnion<ParentCtx, DiscKey, Branches> = {
[BranchName in keyof Branches & string]: ...
}[keyof Branches & string];
```
becomes
```ts
type BuildDiscriminatedUnion<ParentCtx, DiscKey, Branches> = _BuildDiscWithCachedParent<
OmitDistributive<ParentCtx, DiscKey>,
DiscKey,
Branches
>;
```
- Apply the same pattern to `BuildGroupedDiscriminatedUnion` ([line 193](../../src/libs/data-graph/data-graph.ts#L193)).
#### D. Reconsider `const` modifiers on `.node()` overloads
- The three overloads ([943, 965, 996](../../src/libs/data-graph/data-graph.ts#L943)) use `<const K, const Deps, …>`. `const` triggers literal-preserving inference, which is normally cheap but can interact badly with deep distribution.
- **Test:** temporarily drop `const` from one parameter and remeasure. `K` likely needs `const` (we want the literal key in the output type); `Deps` may not.
- Apply per-parameter only if the trace shows it helps.
### Phase 3 — Regression validation
1. `pnpm run typecheck` clean — the canonical regression suite.
2. Spot-check `Extract<GenerationGraphTypes['Ctx'], { ecosystem: 'Ernie' }>` (and a couple of other ecosystems) in the editor; the resolved shape should be unchanged from before. Hover-display getting _less_ prettified is fine; the type members must be identical.
3. Re-run the trace from Phase 1 — confirm the dominant spans actually shrank and by how much.
4. Add one paragraph of comment near whichever helper we touched, citing the depth-budget concern, so the next contributor doesn't reintroduce `Prettify` "for cleaner hovers."
## Files most likely to need changes
- [src/libs/data-graph/data-graph.ts](../../src/libs/data-graph/data-graph.ts) — internal helper types around `Prettify`, `MergeDistributive`, `MergePreferRight`, `BuildDiscriminatedUnion`, and the `.node(...)` / `.computed(...)` / `.merge(...)` overload return types.
## Files to leave alone unless directly forced
- All `*-graph.ts` files in [src/shared/data-graph/generation/](../../src/shared/data-graph/generation/) — they're consumers, not the cost source. Don't reshape ecosystem graphs to dodge depth issues; fix the library.
- [src/shared/data-graph/generation/ecosystem-graph.ts](../../src/shared/data-graph/generation/ecosystem-graph.ts) — the outer composition site. It's where errors _surface_, but the cause is upstream.
## How to verify
```bash
pnpm run typecheck
```
Should exit 0. Then re-run the trace command from Phase 1 and compare the dominant-span numbers against the baseline.
---
## Implementation log (executed)
### Round 1 — Suggestion C only (parent-omit cache)
Hoisted `OmitDistributive<ParentCtx, DiscKey>` into wrapper aliases `_BuildDiscWithCachedParent` and `_BuildGroupedDiscWithCachedParent` so the parent omit is instantiated once per discriminator instead of once per branch.
**Result:** total cold typecheck **90.49s → 89.22s** (-1%), check time **76.41s → 70.38s** (-8%). Depth budget (worst-case shape: groupedDiscriminator + 4 sub-branches + trailing `.node()`) was **24 + 2 = 26 chained calls**.
### Round 2 — Suggestion A (strip `Prettify`) — **REVERTED**
Stripped `Prettify` from `MergeDistributive`, `MergePreferRight`, and `.node()`/`.computed()`/`.merge()` return types.
**Result:** check time **76.41s → 212.20s** (+178%). The plan's premise that "Prettify is purely cosmetic" was wrong for this codebase: removing `Prettify` cuts instantiation count but bloats every downstream structural-comparison cost (deep intersections compare slower than flat objects).
**Lesson:** measure check time, not just instantiations. Reverted entirely.
### Round 3 — Depth-budget reframing
The user clarified that the goal is **type resolution**, not throughput — i.e., raising the TS2589 ceiling so future ecosystems with more nodes/discriminators continue to compile at all. Built a stress harness at [src/libs/data-graph/**stress**/depth-budget.ts](../../src/libs/data-graph/__stress__/depth-budget.ts) that constructs a worst-case graph and adds trailing `.node()` calls until TS2589 fires.
**Findings about the failure mode:**
- When TS2589 fires, the discriminator key silently collapses to `any` and every union branch absorbs every other branch's keys. **Code keeps "compiling" with weakened types** — there's no loud failure, narrowing just silently breaks. The stress test pins typed locals to discriminator-narrowed properties so this failure mode reports as a real type error.
### Round 4 — Two structural changes that lifted the depth budget
1. **Mapped-type fold for `BuildGroupedDiscriminatedUnion`.** Replaced the head/tail recursive shape (`Groups extends [First, ...Rest] ? ... | _Recurse<Rest> : never`) with a constant-depth mapped type (`{ [I in keyof Groups]: ... }[number]`). With 25 ecosystem groups, the recursive form consumed 25 depth units before any trailing `.node()` chain even began.
2. **`AppendKey<A, B> = A & B` for `.node()` and `.computed()`.** These overloads always add a fresh key (the runtime forbids redefinition), so the conditional-type distribution (`A extends unknown ? ... : never`) and `Omit` inside `MergeDistributive` are unnecessary. TypeScript auto-distributes intersection over unions, so plain `A & B` preserves discriminated unions. `.merge()` keeps `MergeDistributive` because its B is the entire ChildCtx and may overlap.
**Result:**
- Depth budget **24 + 2 = 26** → **46 + 2 = 48** chained calls (~85% wider)
- Total cold typecheck **89.22s → 172.65s**
- Check time **70.38s → 150.86s**
- Instantiations **6.92M → 6.53M** (-5.7% — fewer instantiations, but each more expensive due to less pre-flattening)
**Trade-off:** ~95% slower full-repo typecheck for ~85% wider depth budget. Accepted because the depth ceiling is a _hard_ failure (won't compile) while the slowdown is _soft_ (still compiles, takes longer). The user explicitly prioritized resolution over throughput.
**Pinned regression guard:** [src/libs/data-graph/**stress**/depth-budget.ts](../../src/libs/data-graph/__stress__/depth-budget.ts) sits at 40 + 2 = 42 chained calls — comfortably below the 48-call ceiling so transient minor regressions don't trip CI, but high enough that any meaningful regression in the type machinery will show up immediately as TS2589.
### Round 5 — `DistribPrettify` at the extraction boundary
After Round 4 the depth budget was wider but the full-repo check time had nearly doubled. A trace at `--skip-millis 30` showed no concentrated hot spot inside data-graph (~2.5s of 40s focused-config check time was data-graph; the rest was tRPC/markdown/Prisma). The 80s check-time regression was spread thinly across every file in the repo that touches the inferred `Ctx` — a few ms per file from un-Prettified intersections, multiplied by hundreds of files.
Hypothesis: `Prettify` was paying for itself during structural comparison _at use sites_, not during construction. So apply it at the public extraction boundary (`InferDataGraph`) instead of per-chain. Internal accumulator stays cheap (preserving the depth budget), consumers see flat per-branch shapes (fast property access).
```ts
type DistribPrettify<T> = T extends unknown ? { [K in keyof T]: T[K] } : never;
export type InferDataGraph<G> = G extends DataGraph<infer Ctx, ...>
? { Ctx: DistribPrettify<Ctx>; ... }
: never;
```
Distributive matters: a non-distributive `Prettify<X | Y>` would only project keys common to X and Y (since `keyof (X | Y) = keyof X & keyof Y`).
**Result:**
- Total cold typecheck **172.65s → 131.33s** (-24%)
- Check time **150.86s → 110.02s** (-27%)
- Depth budget **unchanged** (still 46 + 2 = 48 chained calls)
- Instantiations 6.54M (essentially unchanged)
- Memory ~3.98GB (unchanged)
### Final tally vs original baseline
| | Baseline | Final | Delta |
| ------------ | -------- | ------- | ----- |
| Total | 90.49s | 131.33s | +45% |
| Check | 76.41s | 110.02s | +44% |
| Depth budget | ~24+2 | 46+2 | +85% |
Net trade-off: **+44% full-repo check time for +85% depth budget.** The TS2589 ceiling is a hard wall (code stops compiling and silently weakens types — see Round 3); the remaining slowdown is soft. Far better balance than Round 4 alone.
### Suggestions B and D — not pursued
- **Suggestion B (non-distributive `MergePreferRight`):** Audit confirmed B-side distribution is load-bearing for nested-discriminator narrowing (the existing comment at line 254 documents this). Skipped without measurement.
- **Suggestion D (drop `const Deps`):** Was lowest-priority and the depth-budget gains from Rounds 1+4 already exceeded the original goal. Not measured.
## What's in the tree now
- [src/libs/data-graph/data-graph.ts](../../src/libs/data-graph/data-graph.ts) — `AppendKey` for `.node()`/`.computed()` accumulator, `DistribPrettify` at `InferDataGraph` boundary, mapped-type-fold for grouped discriminator, parent-omit cache. `MergePreferRight` and `MergeDistributive` keep `Prettify` (the latter is still used by `.merge()`).
- [src/libs/data-graph/**stress**/depth-budget.ts](../../src/libs/data-graph/__stress__/depth-budget.ts) — depth-budget regression guard. Checked by `pnpm typecheck`.
- [src/libs/data-graph/**tests**/data-graph.test.ts](../../src/libs/data-graph/__tests__/data-graph.test.ts) — runtime tests for the small surface area (`.node`, `.discriminator`, `.groupedDiscriminator`, `.computed`, `.merge`, nested-discriminator narrowing).
- [src/shared/data-graph/generation/generation-graph.type-test.ts](../../src/shared/data-graph/generation/generation-graph.type-test.ts) — type-level assertions that the generation graph's narrowing behavior is preserved.
- [tsconfig.stress.json](../../tsconfig.stress.json) — focused tsconfig for fast iteration on the stress test (~5s vs ~170s for the full repo). Optional; `pnpm typecheck` against the main config covers the same ground.
+90 -23
View File
@@ -10,9 +10,49 @@ type InferOutput<T extends AnyZodSchema> = T['_zod']['output'];
/** Utility type that flattens nested intersections for better IDE display */
type Prettify<T> = { [K in keyof T]: T[K] } & NonNullable<unknown>;
/**
* Distributive Prettify — flattens each branch of a discriminated union
* separately. Used at the public extraction boundary (`InferDataGraph`,
* `init()` return) to give consumers a flat object per branch for fast
* property access, without paying the per-chain `Prettify` cost during
* graph construction.
*
* Why distributive: a non-distributive `Prettify<X | Y>` would only project
* the keys common to X and Y (since `keyof (X | Y) = keyof X & keyof Y`).
* Distributing gives `Prettify<X> | Prettify<Y>` — what consumers actually
* want.
*/
type DistribPrettify<T> = T extends unknown ? { [K in keyof T]: T[K] } : never;
/** Merge that distributes over unions in A - preserves discriminated unions and prettifies */
type MergeDistributive<A, B> = A extends unknown ? Prettify<Omit<A, keyof B> & B> : never;
/**
* Append a fresh key to the Ctx accumulator. Used by `.node()` and `.computed()`
* where B is always `{ [K]: V }` for a key that does not exist in any branch of A.
*
* Why plain intersection (no `Omit`, no `extends unknown`, no `Prettify`):
* - `Omit` is unnecessary because `.node()`/`.computed()` reject duplicate keys
* at runtime.
* - `extends unknown` distribution is unnecessary because TypeScript already
* auto-distributes intersection over unions.
* - `Prettify` would re-flatten the entire accumulator on every chained call.
* With many chained `.node()` calls, the nested mapped-type evaluation eats
* the type-instantiation depth budget and trips TS2589 sooner.
*
* Trade-off: skipping `Prettify` per-chain would make downstream structural
* comparisons slower (walking `A & {n1} & {n2} & ... & {n40}` instead of one
* flat object). This is mitigated by `DistribPrettify` at the `InferDataGraph`
* extraction boundary — the accumulator stays cheap during construction, and
* consumers see flat object shapes with fast property access.
*
* Net result: full-repo `tsc` check time +44% vs baseline (with this `Prettify`-
* deferred approach), depth budget +85% (~24 → ~46 post-discriminator chained
* calls). The TS2589 ceiling is a hard wall — code stops compiling — while the
* remaining slowdown is soft. See `src/libs/data-graph/__stress__/depth-budget.ts`.
*/
type AppendKey<A, B> = A & B;
/** Empty object type that works correctly with Merge (unlike Record<string, never>) */
type EmptyObject = Record<never, never>;
@@ -190,25 +230,36 @@ type GroupedBranchesArray<Ctx = any, ExternalCtx = any> = readonly GroupedBranch
*
* This reduces type complexity from O(n) branches to O(groups) branches.
*/
// Perf notes:
// - `OmitDistributive<ParentCtx, DiscKey>` is hoisted into a wrapper alias so
// it is instantiated once per discriminator instead of once per group.
// - The branch fold uses a mapped type over the tuple (`{ [I in keyof Groups]: ... }[number]`)
// instead of head-tail recursion. This is constant-depth regardless of how many
// groups exist, which preserves the type-instantiation depth budget for trailing
// `.node()` chains. Recursion-based folds added one depth level per group; with
// 25 ecosystem groups that consumed 25 depth units before any trailing node chain
// even started.
type BuildGroupedDiscriminatedUnion<
ParentCtx extends Record<string, unknown>,
DiscKey extends string,
Groups extends GroupedBranchesArray
> = Groups extends readonly [infer First, ...infer Rest]
? First extends GroupedBranch<infer Values, infer Graph>
?
| MergePreferRight<
OmitDistributive<ParentCtx, DiscKey>,
MergePreferRight<
{ [K in DiscKey]: Values[number] },
OmitDistributive<InferGraphContext<Graph>, DiscKey>
>
>
| (Rest extends GroupedBranchesArray
? BuildGroupedDiscriminatedUnion<ParentCtx, DiscKey, Rest>
: never)
: never
: never;
> = _BuildGroupedDiscWithCachedParent<OmitDistributive<ParentCtx, DiscKey>, DiscKey, Groups>;
type _BuildGroupedDiscWithCachedParent<
OmittedParent,
DiscKey extends string,
Groups extends GroupedBranchesArray
> = {
[I in keyof Groups]: Groups[I] extends GroupedBranch<infer Values, infer Graph>
? MergePreferRight<
OmittedParent,
MergePreferRight<
{ [K in DiscKey]: Values[number] },
OmitDistributive<InferGraphContext<Graph>, DiscKey>
>
>
: never;
}[number];
/**
* Lazy evaluation of grouped branch metas via mapped type.
@@ -281,15 +332,25 @@ type BranchShape<
// - Parent's discriminated union (from earlier discriminators) is preserved
// - Branch's discriminated union (from nested discriminators) is preserved
// This allows type narrowing to work for nested discriminators like: workflow -> input -> images
//
// Perf note: `OmitDistributive<ParentCtx, DiscKey>` is hoisted into a wrapper alias
// so it is instantiated once per discriminator instead of once per branch.
// Without this hoist, a graph with N branches re-evaluates the parent omit N times.
type BuildDiscriminatedUnion<
ParentCtx extends Record<string, unknown>,
DiscKey extends string,
Branches extends BranchesRecord
> = _BuildDiscWithCachedParent<OmitDistributive<ParentCtx, DiscKey>, DiscKey, Branches>;
type _BuildDiscWithCachedParent<
OmittedParent,
DiscKey extends string,
Branches extends BranchesRecord
> = {
// Map each branch name to its complete shape (merged with parent, subgraph types preferred)
[BranchName in keyof Branches & string]: MergePreferRight<
OmitDistributive<ParentCtx, DiscKey>,
BranchShape<ParentCtx, DiscKey, BranchName, Branches[BranchName]>
OmittedParent,
BranchShape<Record<string, unknown>, DiscKey, BranchName, Branches[BranchName]>
>;
}[keyof Branches & string];
@@ -952,7 +1013,7 @@ export class DataGraph<
transform?: (value: InferOutput<T>, ctx: Ctx, ext: ExternalCtx) => InferOutput<T>;
}
): DataGraph<
MergeDistributive<Ctx, { [P in K]: InferOutput<T> }>,
AppendKey<Ctx, { [P in K]: InferOutput<T> }>,
ExternalCtx,
M extends undefined ? CtxMeta : Prettify<CtxMeta & { [P in K]: M }>,
Prettify<CtxValues & { [P in K]: InferOutput<T> }>
@@ -980,7 +1041,7 @@ export class DataGraph<
},
deps: Deps
): DataGraph<
MergeDistributive<Ctx, { [P in K]: InferOutput<T> }>,
AppendKey<Ctx, { [P in K]: InferOutput<T> }>,
ExternalCtx,
M extends undefined ? CtxMeta : Prettify<CtxMeta & { [P in K]: M }>,
Prettify<CtxValues & { [P in K]: InferOutput<T> }>
@@ -1011,7 +1072,7 @@ export class DataGraph<
},
deps: Deps
): DataGraph<
MergeDistributive<Ctx, { [P in K]?: InferOutput<T> }>,
AppendKey<Ctx, { [P in K]?: InferOutput<T> }>,
ExternalCtx,
M extends undefined ? CtxMeta : Prettify<CtxMeta & { [P in K]: M }>,
Prettify<CtxValues & { [P in K]: InferOutput<T> }>
@@ -1251,7 +1312,7 @@ export class DataGraph<
compute: (ctx: Ctx, ext: ExternalCtx) => T,
deps: Deps
): DataGraph<
MergeDistributive<Ctx, { [P in K]: T }>,
AppendKey<Ctx, { [P in K]: T }>,
ExternalCtx,
CtxMeta,
Prettify<CtxValues & { [P in K]: T }>
@@ -2043,8 +2104,14 @@ export type InferDataGraph<G> = G extends DataGraph<
infer CtxValues
>
? {
/** The context type containing all node values (discriminated union) */
Ctx: Ctx;
/**
* The context type containing all node values (discriminated union).
*
* Branches are distributively prettified at extraction time — each branch
* is flattened from `(branch & {n1} & {n2} & ...)` into a plain object so
* downstream property access doesn't walk the intersection chain.
*/
Ctx: DistribPrettify<Ctx>;
/** External context passed from outside the graph */
ExternalCtx: ExternalCtx;
/** Per-node meta type mapping (intersection of all branch metas) */