DEV Community

Cover image for Notes from a 2 AM On-Call: Why TypeScript Inference Broke Our Monorepo Build Pipeline
Ryan Gallagher
Ryan Gallagher

Posted on

Notes from a 2 AM On-Call: Why TypeScript Inference Broke Our Monorepo Build Pipeline

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)
Enter fullscreen mode Exit fullscreen mode

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.json outputs globs
  • Missing tsconfig.json composite 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"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Key changes:

  • Explicit empty env array prevents accidental env var inclusion
  • globalPassThroughEnv: null blocks 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:

  1. Add a Depth parameter defaulting to 5-10
  2. Use tuple-indexed countdown: [-1, 0, 1, 2, 3, 4][Depth]
  3. Validate in CI that .tsbuildinfo hashes 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)