Notes from a 2 AM On-Call: Why TypeScript Inference Broke Our Monorepo Build Pipeline
The Failure Symptom
[12:47:03] packages/core:build: cache miss, executing...
[12:51:22] packages/cli:build: cache miss, executing...
[13:26:48] Build completed (2,405s)
Our Turborepo pipeline suddenly lost all cache hits. Every single package rebuilt from scratch. CI runtime jumped from 4 minutes to 40 minutes. Zero TypeScript errors. Zero test failures. Just silent, complete cache invalidation across 18 internal packages.
The trigger? Adding a single utility type to our shared @workspace/types package while integrating anomalyco/opencode's agent runtime into our CLI harness.
The Red Herring
Stack Overflow and the Turborepo docs all pointed to obvious culprits:
- Incorrect
turbo.jsonoutputs globs - Missing
tsconfig.jsoncomposite references - Environmental variable leakage into build hashes
We audited all three. Our turbo.json was textbook:
{
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".turbo/**"],
"inputs": ["src/**/*.ts", "tsconfig.json", "package.json"]
}
}
}
Composite references were correct. No env vars in scope. Yet every commit triggered a full rebuild.
The Root Cause
The culprit was a recursive conditional type with unbounded inference:
// packages/types/src/agent.ts
export type DeepExtract<T, K extends string> =
K extends `${infer First}.${infer Rest}`
? First extends keyof T
? DeepExtract<T[First], Rest>
: never
: K extends keyof T
? T[K]
: never;
This type powered our anomalyco/opencode configuration parser. It worked perfectly at runtime. But TypeScript 5.8's compiler internally generates ephemeral type instantiation metadata during inference that varies non-deterministically across compilation sessions—even with identical source.
Turborepo's cache hash includes .tsbuildinfo metadata. When DeepExtract instantiated across 12 different consuming packages, each compilation produced subtly different internal type graphs. Different graphs → different .tsbuildinfo checksums → cache miss.
The 3-Line Fix
Constrain instantiation depth explicitly:
export type DeepExtract<
T,
K extends string,
Depth extends number = 5
> = Depth extends 0
? never
: K extends `${infer First}.${infer Rest}`
? First extends keyof T
? DeepExtract<T[First], Rest, [-1, 0, 1, 2, 3, 4][Depth]>
: never
: K extends keyof T
? T[K]
: never;
The tuple-indexed depth counter forces deterministic instantiation. TypeScript now generates identical internal graphs across all compilation units. .tsbuildinfo hashes stabilize. Cache hits return.
CI runtime: back to 4m 12s.
Turborepo Pipeline Hardening
We also added strict input hashing to prevent future silent invalidations:
{
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"],
"inputs": [
"src/**/*.ts",
"tsconfig.json",
"package.json",
"!src/**/*.test.ts",
"!src/**/__tests__/**"
],
"env": [],
"passThroughEnv": []
}
},
"globalEnv": ["CI"],
"globalPassThroughEnv": null
}
Key changes:
- Explicit empty
envarray prevents accidental env var inclusion -
globalPassThroughEnv: nullblocks all parent shell pollution - Test file exclusions prevent dev-time churn from triggering production rebuilds
Invariant Rule
Recursive conditional types in shared monorepo packages must have explicit depth bounds or tail-call optimization.
If a utility type recurses on template literal inference or mapped type iteration:
- Add a
Depthparameter defaulting to 5-10 - Use tuple-indexed countdown:
[-1, 0, 1, 2, 3, 4][Depth] - Validate in CI that
.tsbuildinfohashes remain stable across consecutive builds of the same commit
When we integrated anomalyco/opencode's agent orchestration layer, we caught three more unbounded recursions in our Prettify<T> and DeepPartial<T> helpers using this audit. Prevented the same cache invalidation nightmare before production.
The 2 AM page was worth it. Our monorepo is now deterministic.
Top comments (0)