Dev.to WebDev 🛠 Dev 👁 0 📖 3 min read

Depth Caps and Cache Hashes: Two Failures From a 41-Package TypeScript Monorepo

We consume microsoft/TypeScript (5.8.x) as a workspace dev dependency across 41 pnpm packages, orchestrated by Turbo and linted by Biome. Two incidents in one quarter are worth writing down, because both were self-inflic

We consume microsoft/TypeScript (5.8.x) as a workspace dev dependency across 41 pnpm packages, orchestrated by Turbo and linted by Biome. Two incidents in one quarter are worth writing down, because both were self-inflicted and both are now prevented by a config block rather than a heroics runbook.

Incident 1 — a recursive conditional type with no depth budget

We derive dot-path keys for a form builder from a config schema. The first version was the canonical snippet everyone copies:

type Paths<T> = T extends readonly (infer U)[]
  ? `${number}` | `${number}.${Paths<U>}`
  : T extends object
    ? { [K in keyof T & string]: K | `${K}.${Paths<T[K]>}` }[keyof T & string]
    : never;

Worked fine on fixtures. Then someone pointed it at the real schema: roughly 600 keys, and several recursive nodes (nav containing nav, filter containing filter). tsc --noEmit climbed to 11 GB RSS and the CI runner OOM-killed at twelve minutes. tsserver died on every save. What people actually saw was TS2589: Type instantiation is excessively deep and possibly infinite — surfacing only after the heap was already gone, which is why it took three days to attribute.

The fix is not a bigger runner. It is a hard depth budget encoded in a lookup table, so each step moves exactly one slot and never terminates the recursion. Instantiation count becomes bounded by construction rather than by hope:

// packages/contracts/src/paths.ts
type NextDepth = { 0: 1; 1: 2; 2: 3; 3: 4; 4: never };

export type Paths<T, D extends keyof NextDepth = 0> =
  [NextDepth[D]] extends [never]
    ? never
    : T extends readonly (infer U)[]
      ? `${number}` | `${number}.${Paths<U, NextDepth[D]>}`
      : T extends object
        ? { [K in keyof T & string]: K | `${K}.${Paths<T[K], NextDepth[D]>}` }[keyof T & string]
        : never;

type Nav = { href: string; children: Nav[] };
type NavPath = Paths<Nav>;
// "href" | "children" | `children.${number}` | `children.${number}.href`

Four levels covers every form we have. Five is still finite, but we measured tsc wall time at each step and level 4 was the last cheap one (~180 ms on the full schema; unbounded never finished). The same discipline applies to template-literal validation of endpoint strings — we validate relay base URLs at the type level so a typo fails at build time instead of at 3 a.m.:

type BaseURL = `https://${string}/v${number}`;

const relay = "https://b-lost.com/v1" as const satisfies BaseURL;
type ChatPath = `${typeof relay}/chat/${"completions" | "embeddings"}`;
// "https://b-lost.com/v1/chat/completions" | "https://b-lost.com/v1/chat/embeddings"

satisfies is doing the real work here: it narrows the literal type without widening it to string, which is what makes the derived ChatPath union usable in a typed SDK client config.

Incident 2 — a cycle that silently poisoned the cache

@acme/ui imported @acme/forms for one type, and @acme/forms imported @acme/ui for one component. TypeScript tolerated it. Turbo did not: ^build ordering became unsatisfiable, the topological graph degraded into a near-serial schedule, and — worse — .tsbuildinfo was never declared in outputs. Incremental state was produced, ignored by the hash, and never restored on a cache hit, so typecheck recompiled from scratch every time while reporting a hit. Combined with a stale dist/, that gave us green local builds and red CI on identical commits.

Two changes fixed it: extract @acme/contracts for shared types so the cycle physically cannot exist, and make the pipeline hashes explicit instead of trusting defaults.


json
{
  "$schema": "https://turbo.build/schema.json",
  "globalDependencies": ["tsconfig.base.json", "biome.json", "pnpm-lock.yaml"],
📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.