{"schemaVersion":1,"id":"https://lucioduran.com/blog/typescript-5-5-type-predicates-inference","url":"https://lucioduran.com/blog/typescript-5-5-type-predicates-inference","title":"TypeScript 5.5: Inferred Type Predicates, Isolated Declarations, and Real-World Gotchas","description":"Analysis of TypeScript 5.5's biggest features: inferred type predicates that actually work, isolated declarations for faster builds, config extends improvements, regex type narrowing, and patterns that will bite you.","language":"en","datePublished":"2025-12-24","dateModified":null,"contentFormat":"text/markdown","content":"TypeScript\n\n# TypeScript 5.5: Inferred Type Predicates, Isolated Declarations, and Real-World Gotchas\n\n2025-12-2410 min read\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/typescript-5-5-type-predicates-inference>)\n\n## Inferred type predicates: the biggest quality-of-life improvement in years\n\nThis is arguably the most anticipated quality-of-life improvement for TypeScript developers. Consider the most common pattern in any codebase:\n\n```typescript\n// Before TS 5.5\nconst values: (string | null)[] = [\"hello\", null, \"world\", null, \"!\"];\n\n// This SHOULD give us string[], but it doesn't\nconst filtered = values.filter((v) => v !== null);\n// Type: (string | null)[], WRONG! We just filtered out nulls!\n\n// The workaround was this ugly mess\nconst filteredCorrect = values.filter((v): v is string => v !== null);\n// Type: string[], Correct, but annoying boilerplate\n```\n\nIn [TypeScript](<https://www.typescriptlang.org/>) 5.5, the compiler automatically infers that ` (v) => v !== null ` is a type predicate. No annotation needed:\n\n```typescript\n// TypeScript 5.5\nconst values: (string | null)[] = [\"hello\", null, \"world\", null, \"!\"];\n\nconst filtered = values.filter((v) => v !== null);\n// Type: string[], FINALLY!\n\n// Works with more complex patterns too\ninterface User {\n id: string;\n name: string;\n email: string | null;\n deletedAt: Date | null;\n}\n\nconst users: User[] = getUsers();\n\n// Filter active users with email\nconst activeWithEmail = users.filter((u) => {\n return u.deletedAt === null && u.email !== null;\n});\n// Type is still User[], not narrowed on properties\n// BUT this works:\n\nfunction hasEmail(user: User): user is User & { email: string } {\n return user.email !== null;\n}\n// This predicate IS now inferred if you use it in a filter\n```\n\n### Where the inference works (and where it doesn't)\n\nThe inference has specific rules you need to know:\n\n```typescript\n// WORKS: explicit return statement\nconst isString = (x: unknown) => {\n return typeof x === \"string\";\n};\n// Inferred: (x: unknown) => x is string\n\n// DOESN'T WORK: expression body (no braces)\nconst isStringExpr = (x: unknown) => typeof x === \"string\";\n// Inferred: (x: unknown) => boolean, NO predicate!\n// This is because expression bodies could have side effects\n// that make predicate inference unsound\n\n// WORKS: null/undefined checks\nconst isDefined = <T>(x: T | null | undefined) => {\n return x !== null && x !== undefined;\n};\n// Inferred: (x: T | null | undefined) => x is T\n\n// WORKS: instanceof checks\nconst isDate = (x: unknown) => {\n return x instanceof Date;\n};\n// Inferred: (x: unknown) => x is Date\n\n// WORKS: property checks\nconst hasName = (x: object) => {\n return \"name\" in x;\n};\n// Inferred: (x: object) => x is object & Record<\"name\", unknown>\n\n// DOESN'T WORK: complex conditions with side effects\nlet sideEffect = false;\nconst isBad = (x: unknown) => {\n sideEffect = true; // Side effect prevents inference\n return typeof x === \"string\";\n};\n// Inferred: (x: unknown) => boolean\n```\n\n### The migration gotcha\n\nHere is a common migration pitfall. Consider code like this:\n\n```typescript\ninterface ApiResponse {\n data: User | null;\n error: string | null;\n}\n\nconst responses: ApiResponse[] = await Promise.all(requests);\n\n// Before TS 5.5: type was ApiResponse[]\n// After TS 5.5: type is still ApiResponse[], BUT...\nconst successful = responses.filter((r) => r.data !== null);\n\n// Before: successful[0].data was User | null (no narrowing)\n// After: TypeScript now narrows better through filter\n\n// The PROBLEM: downstream code that was doing redundant null checks\n// now gets flagged because TS knows the value can't be null\nsuccessful.forEach((r) => {\n if (r.data === null) {\n // TS 5.5: Error! This condition will always be false\n // Because .filter() already narrowed it\n return;\n }\n processUser(r.data);\n});\n```\n\nThe fix is usually to remove the now-redundant null checks. But if you have a large codebase, this means dozens of changes. A practical approach: fix them gradually with ` @ts-expect-error ` comments and a lint rule to clean them up over time.\n\n## Isolated declarations: monorepo build speed revolution\n\nThis is the feature that matters most for large teams. ` isolatedDeclarations ` is a new compiler option that requires exported functions and values to have explicit return type annotations:\n\n```typescript\n// tsconfig.json\n{\n \"compilerOptions\": {\n \"isolatedDeclarations\": true,\n \"declaration\": true\n }\n}\n\n// WITHOUT isolatedDeclarations (implicit return types are fine)\nexport function add(a: number, b: number) {\n return a + b; // Return type inferred as: number\n}\n\n// WITH isolatedDeclarations (explicit return type required)\nexport function add(a: number, b: number): number {\n return a + b;\n}\n\n// Error without explicit return type:\nexport function processData(input: string) {\n // ^^^ Error!\n // Exported function must have an explicit return type annotation\n // with --isolatedDeclarations\n const parsed = JSON.parse(input);\n return { ...parsed, processed: true };\n}\n\n// Fix: add explicit return type\ninterface ProcessedData {\n processed: true;\n [key: string]: unknown;\n}\n\nexport function processData(input: string): ProcessedData {\n const parsed = JSON.parse(input);\n return { ...parsed, processed: true };\n}\n```\n\nWhy does this matter? Because with explicit types on all exports, a tool can generate ` .d.ts ` files for a package **without type-checking the entire package**. This means:\n\n1. **Parallel .d.ts generation**: Each package in your monorepo can generate declarations independently\n2. **Incremental builds**: Only regenerate declarations for packages that changed\n3. **Third-party tools**: Tools like ` oxc `, ` swc `, and ` esbuild ` can generate ` .d.ts ` files without running the full TypeScript compiler\n\nFor a monorepo with 23 packages, this cuts ` tsc --build ` time from 94 seconds to 31 seconds. The TypeScript team reports even larger improvements on bigger monorepos.\n\n### Gradual adoption strategy\n\nYou don't need to enable it everywhere at once:\n\n```json\n// packages/shared-utils/tsconfig.json (leaf package, start here)\n{\n \"extends\": \"../../tsconfig.base.json\",\n \"compilerOptions\": {\n \"isolatedDeclarations\": true\n }\n}\n\n// packages/api-client/tsconfig.json (depends on shared-utils)\n// Enable AFTER shared-utils is migrated\n{\n \"extends\": \"../../tsconfig.base.json\",\n \"compilerOptions\": {\n \"isolatedDeclarations\": true\n }\n}\n\n// apps/web/tsconfig.json (app, no need to enable)\n// Apps typically don't export .d.ts files\n{\n \"extends\": \"../../tsconfig.base.json\"\n}\n```\n\nStart with your leaf packages (no internal dependencies), then work inward. App packages don't need it because they don't produce declarations.\n\nThe ` --declaration --isolatedDeclarations ` fix-it hint is your friend. Run ` tsc ` with these flags and it'll tell you exactly which functions need explicit annotations. We automated this with a script:\n\n```bash\n# Find all missing return types\nnpx tsc --noEmit --isolatedDeclarations 2>&1 | \\\n grep \"TS9007\" | \\\n awk -F'[:(]' '{print $1 \":\" $2}' | \\\n sort -u\n```\n\n## Config extends: the ${configDir} template variable\n\nTypeScript 5.5 introduces ` ${configDir} ` as a template variable in tsconfig paths. This solves a long-standing pain point in monorepos:\n\n```json\n// BEFORE: paths are relative to the extending config's location\n// tsconfig.base.json\n{\n \"compilerOptions\": {\n \"outDir\": \"./dist\", // Relative to... which file?\n \"rootDir\": \"./src\", // Changes meaning when extended\n \"paths\": {\n \"@/*\": [\"./src/*\"] // Breaks in nested configs\n }\n }\n}\n\n// AFTER: ${configDir} always refers to the directory containing the file\n// tsconfig.base.json\n{\n \"compilerOptions\": {\n \"outDir\": \"${configDir}/dist\",\n \"rootDir\": \"${configDir}/src\",\n \"paths\": {\n \"@/*\": [\"${configDir}/src/*\"]\n }\n }\n}\n```\n\nNow when ` packages/api/tsconfig.json ` extends ` ../../tsconfig.base.json `, ` ${configDir} ` resolves to ` packages/api/ `, not the root. No more broken paths.\n\nThis is especially useful for the pattern where you have different configs for different concerns:\n\n```json\n// tsconfig.base.json, shared compiler options\n{\n \"compilerOptions\": {\n \"target\": \"ES2022\",\n \"module\": \"NodeNext\",\n \"strict\": true,\n \"outDir\": \"${configDir}/dist\",\n \"rootDir\": \"${configDir}/src\",\n \"declarationDir\": \"${configDir}/dist/types\"\n }\n}\n\n// tsconfig.test.json, test configuration\n{\n \"extends\": \"./tsconfig.base.json\",\n \"compilerOptions\": {\n \"rootDir\": \"${configDir}\", // Include test files\n \"types\": [\"vitest/globals\"]\n },\n \"include\": [\"${configDir}/src/**/*\", \"${configDir}/tests/**/*\"]\n}\n```\n\n## Regex type narrowing\n\nTypeScript 5.5 adds type narrowing through regular expression ` .test() ` and ` .match() ` calls. This is more useful than it sounds:\n\n```typescript\nfunction processInput(value: string | number) {\n if (typeof value === \"string\") {\n // value is string here\n\n if (/^\\d+$/.test(value)) {\n // value is still string, but TS now knows it matched the regex\n // This helps with overload resolution and template literal types\n const num = parseInt(value, 10); // TS knows this is safe\n }\n }\n}\n\n// More practical: discriminating string formats\ntype EmailString = `${string}@${string}.${string}`;\ntype UrlString = `https://${string}`;\n\nfunction categorize(input: string): \"email\" | \"url\" | \"other\" {\n if (/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(input)) {\n return \"email\";\n }\n if (/^https:\\/\\//.test(input)) {\n return \"url\";\n }\n return \"other\";\n}\n\n// Where it DOESN'T work\nconst result = \"hello\".search(/pattern/);\n// search returns a number, not a boolean, no narrowing\n\nconst replaced = \"hello\".replace(/h/, \"H\");\n// replace returns a string, no narrowing\n```\n\n## Real-world patterns that got better\n\nHere are some patterns from a real codebase that improved with 5.5:\n\n### Discriminated union filtering\n\n```typescript\ntype Event =\n | { type: \"click\"; x: number; y: number }\n | { type: \"keypress\"; key: string }\n | { type: \"scroll\"; delta: number };\n\nconst events: Event[] = getEvents();\n\n// Before TS 5.5: needed explicit predicate\nconst clicks = events.filter(\n (e): e is Extract<Event, { type: \"click\" }> => e.type === \"click\"\n);\n\n// After TS 5.5: predicate is inferred\nconst clicks = events.filter((e) => {\n return e.type === \"click\";\n});\n// Type: { type: \"click\"; x: number; y: number }[]\n```\n\n### Array.from with map and filter\n\n```typescript\n// Transform and filter in one pass\nconst validEntries = Array.from(formData.entries())\n .map(([key, value]) => {\n if (typeof value !== \"string\") return null;\n if (value.trim() === \"\") return null;\n return { key, value: value.trim() };\n })\n .filter((entry) => {\n return entry !== null;\n });\n// TS 5.5: { key: string; value: string }[]\n// TS 5.4: ({ key: string; value: string } | null)[]\n```\n\n### Zod-like schema validation\n\n```typescript\n// Type-safe validation pipeline\nfunction createValidator<T>(schema: {\n validate: (input: unknown) => input is T;\n}) {\n return {\n parse(data: unknown): T {\n if (schema.validate(data)) {\n return data; // Properly narrowed!\n }\n throw new Error(\"Validation failed\");\n },\n // In 5.5, this filter works correctly\n filterValid(items: unknown[]): T[] {\n return items.filter((item) => {\n return schema.validate(item);\n });\n },\n };\n}\n```\n\n## The performance numbers\n\nReal numbers from a monorepo migration (23 packages, ~180K lines of TypeScript):\n\n| Metric | TS 5.4 | TS 5.5 | TS 5.5 + isolatedDecl |\n| --- | --- | --- | --- |\n| Full build | 94s | 89s | 31s |\n| Incremental (1 file) | 12s | 11s | 3.2s |\n| Declaration gen | 28s | 26s | 8.1s |\n| IDE responsiveness | ~800ms | ~650ms | ~650ms |\n\nThe ` isolatedDeclarations ` improvement is the headline number. Going from 94s to 31s for a full build is the difference between \"grab a coffee\" and \"it's already done.\"\n\n## Known upgrade considerations\n\nA few things worth knowing before upgrading:\n\n1. **Inferred predicates can widen your types**: If a function previously returned ` boolean ` and now returns ` x is Foo `, code that depended on the boolean return value might behave differently with control flow analysis.\n2. **isolatedDeclarations + declaration maps**: If you use ` declarationMap `, the maps now point to the original source even without full type checking. Make sure your source maps configuration is consistent.\n3. **The ` satisfies ` + inferred predicates interaction**: ` satisfies ` doesn't preserve the inferred predicate. If you have ` const isString = ((x: unknown) => typeof x === 'string') satisfies (x: unknown) => boolean `, the predicate is lost.\n4. **Performance regression with deeply nested conditional types**: There is a known case where TS 5.5's new type inference causes a 10x slowdown on a specific file with deeply nested ` Omit<Pick<Partial<...>>> ` chains. The fix was to simplify the types.\n\nTypeScript 5.5 is a genuinely great release. Inferred type predicates fix one of the most common papercuts in the language. Isolated declarations unlock real build speed improvements for monorepos. And the config improvements make shared configurations actually work. Upgrade carefully, expect some type errors that are actually bug fixes, and enjoy the ride.\n\n## Frequently asked questions\n\n### What are inferred type predicates in TypeScript 5.5?\n\nTypeScript 5.5 can automatically infer that a function like (x) =\\> x !== null acts as a type predicate (x is NonNullable\\<T\\>), without you having to write the 'is' annotation manually. This makes .filter(Boolean) and similar patterns work correctly with type narrowing.\n\n### What is isolatedDeclarations and why should I care?\n\nisolatedDeclarations requires that all exported functions and values have explicit type annotations, enabling declaration file generation without full type-checking. This unlocks parallel and incremental .d.ts generation, dramatically speeding up monorepo builds.\n\n### Does TypeScript 5.5 break existing code?\n\nPotentially yes. Inferred type predicates can change the inferred types in existing .filter() calls, which might reveal previously hidden type errors. The fix is usually correct, the old behavior was technically wrong, but it can be surprising.\n\n## Tips\n\n1. **Tip:** Inferred type predicates only work with ` return ` statements, arrow functions with expression bodies like ` (x) => x !== null ` won't infer predicates. Wrap them in braces.\n2. **Tip:** Enable ` isolatedDeclarations ` gradually, start with your leaf packages in a monorepo and work inward. It requires explicit return types on exported functions.\n3. **Tip:** The ` configDir ` template variable in tsconfig extends significantly improves workflow in monorepos, use ` ${configDir}/src ` instead of relative paths that break when configs are nested.\n4. **Tip:** Regex type narrowing only works with ` .test() ` and ` .match() `, ` .search() ` and ` .replace() ` don't narrow types. This catches people off guard constantly.\n\n- typescript\n- type-predicates\n- isolated-declarations\n- type-narrowing\n- javascript\n- dx\n- compiler\n- type-system\n\n[Back to Blog](<https://lucioduran.com/blog>)","headings":[{"level":1,"text":"TypeScript 5.5: Inferred Type Predicates, Isolated Declarations, and Real-World Gotchas","url":"https://lucioduran.com/blog/typescript-5-5-type-predicates-inference#title"},{"level":2,"text":"Inferred type predicates: the biggest quality-of-life improvement in years","url":"https://lucioduran.com/blog/typescript-5-5-type-predicates-inference#inferred-type-predicates-the-biggest-quality-of-life-improvement-in-years"},{"level":3,"text":"Where the inference works (and where it doesn't)","url":"https://lucioduran.com/blog/typescript-5-5-type-predicates-inference#where-the-inference-works-and-where-it-doesnt"},{"level":3,"text":"The migration gotcha","url":"https://lucioduran.com/blog/typescript-5-5-type-predicates-inference#the-migration-gotcha"},{"level":2,"text":"Isolated declarations: monorepo build speed revolution","url":"https://lucioduran.com/blog/typescript-5-5-type-predicates-inference#isolated-declarations-monorepo-build-speed-revolution"},{"level":3,"text":"Gradual adoption strategy","url":"https://lucioduran.com/blog/typescript-5-5-type-predicates-inference#gradual-adoption-strategy"},{"level":2,"text":"Config extends: the ${configDir} template variable","url":"https://lucioduran.com/blog/typescript-5-5-type-predicates-inference#config-extends-the-configdir-template-variable"},{"level":2,"text":"Regex type narrowing","url":"https://lucioduran.com/blog/typescript-5-5-type-predicates-inference#regex-type-narrowing"},{"level":2,"text":"Real-world patterns that got better","url":"https://lucioduran.com/blog/typescript-5-5-type-predicates-inference#real-world-patterns-that-got-better"},{"level":3,"text":"Discriminated union filtering","url":"https://lucioduran.com/blog/typescript-5-5-type-predicates-inference#discriminated-union-filtering"},{"level":3,"text":"Array.from with map and filter","url":"https://lucioduran.com/blog/typescript-5-5-type-predicates-inference#arrayfrom-with-map-and-filter"},{"level":3,"text":"Zod-like schema validation","url":"https://lucioduran.com/blog/typescript-5-5-type-predicates-inference#zod-like-schema-validation"},{"level":2,"text":"The performance numbers","url":"https://lucioduran.com/blog/typescript-5-5-type-predicates-inference#the-performance-numbers"},{"level":2,"text":"Known upgrade considerations","url":"https://lucioduran.com/blog/typescript-5-5-type-predicates-inference#known-upgrade-considerations"},{"level":2,"text":"Frequently asked questions","url":"https://lucioduran.com/blog/typescript-5-5-type-predicates-inference#questions"},{"level":3,"text":"What are inferred type predicates in TypeScript 5.5?","url":"https://lucioduran.com/blog/typescript-5-5-type-predicates-inference#question-1"},{"level":3,"text":"What is isolatedDeclarations and why should I care?","url":"https://lucioduran.com/blog/typescript-5-5-type-predicates-inference#question-2"},{"level":3,"text":"Does TypeScript 5.5 break existing code?","url":"https://lucioduran.com/blog/typescript-5-5-type-predicates-inference#question-3"},{"level":2,"text":"Tips","url":"https://lucioduran.com/blog/typescript-5-5-type-predicates-inference#article-tips"}],"sections":[{"heading":"TypeScript 5.5: Inferred Type Predicates, Isolated Declarations, and Real-World Gotchas","level":1,"url":"https://lucioduran.com/blog/typescript-5-5-type-predicates-inference","content":"TypeScript"},{"heading":"TypeScript 5.5: Inferred Type Predicates, Isolated Declarations, and Real-World Gotchas","level":1,"url":"https://lucioduran.com/blog/typescript-5-5-type-predicates-inference#title","content":"2025-12-2410 min read\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/typescript-5-5-type-predicates-inference>)"},{"heading":"Inferred type predicates: the biggest quality-of-life improvement in years","level":2,"url":"https://lucioduran.com/blog/typescript-5-5-type-predicates-inference#inferred-type-predicates-the-biggest-quality-of-life-improvement-in-years","content":"This is arguably the most anticipated quality-of-life improvement for TypeScript developers. Consider the most common pattern in any codebase:\n\n```typescript\n// Before TS 5.5\nconst values: (string | null)[] = [\"hello\", null, \"world\", null, \"!\"];\n\n// This SHOULD give us string[], but it doesn't\nconst filtered = values.filter((v) => v !== null);\n// Type: (string | null)[], WRONG! We just filtered out nulls!\n\n// The workaround was this ugly mess\nconst filteredCorrect = values.filter((v): v is string => v !== null);\n// Type: string[], Correct, but annoying boilerplate\n```\n\nIn [TypeScript](<https://www.typescriptlang.org/>) 5.5, the compiler automatically infers that ` (v) => v !== null ` is a type predicate. No annotation needed:\n\n```typescript\n// TypeScript 5.5\nconst values: (string | null)[] = [\"hello\", null, \"world\", null, \"!\"];\n\nconst filtered = values.filter((v) => v !== null);\n// Type: string[], FINALLY!\n\n// Works with more complex patterns too\ninterface User {\n id: string;\n name: string;\n email: string | null;\n deletedAt: Date | null;\n}\n\nconst users: User[] = getUsers();\n\n// Filter active users with email\nconst activeWithEmail = users.filter((u) => {\n return u.deletedAt === null && u.email !== null;\n});\n// Type is still User[], not narrowed on properties\n// BUT this works:\n\nfunction hasEmail(user: User): user is User & { email: string } {\n return user.email !== null;\n}\n// This predicate IS now inferred if you use it in a filter\n```"},{"heading":"Where the inference works (and where it doesn't)","level":3,"url":"https://lucioduran.com/blog/typescript-5-5-type-predicates-inference#where-the-inference-works-and-where-it-doesnt","content":"The inference has specific rules you need to know:\n\n```typescript\n// WORKS: explicit return statement\nconst isString = (x: unknown) => {\n return typeof x === \"string\";\n};\n// Inferred: (x: unknown) => x is string\n\n// DOESN'T WORK: expression body (no braces)\nconst isStringExpr = (x: unknown) => typeof x === \"string\";\n// Inferred: (x: unknown) => boolean, NO predicate!\n// This is because expression bodies could have side effects\n// that make predicate inference unsound\n\n// WORKS: null/undefined checks\nconst isDefined = <T>(x: T | null | undefined) => {\n return x !== null && x !== undefined;\n};\n// Inferred: (x: T | null | undefined) => x is T\n\n// WORKS: instanceof checks\nconst isDate = (x: unknown) => {\n return x instanceof Date;\n};\n// Inferred: (x: unknown) => x is Date\n\n// WORKS: property checks\nconst hasName = (x: object) => {\n return \"name\" in x;\n};\n// Inferred: (x: object) => x is object & Record<\"name\", unknown>\n\n// DOESN'T WORK: complex conditions with side effects\nlet sideEffect = false;\nconst isBad = (x: unknown) => {\n sideEffect = true; // Side effect prevents inference\n return typeof x === \"string\";\n};\n// Inferred: (x: unknown) => boolean\n```"},{"heading":"The migration gotcha","level":3,"url":"https://lucioduran.com/blog/typescript-5-5-type-predicates-inference#the-migration-gotcha","content":"Here is a common migration pitfall. Consider code like this:\n\n```typescript\ninterface ApiResponse {\n data: User | null;\n error: string | null;\n}\n\nconst responses: ApiResponse[] = await Promise.all(requests);\n\n// Before TS 5.5: type was ApiResponse[]\n// After TS 5.5: type is still ApiResponse[], BUT...\nconst successful = responses.filter((r) => r.data !== null);\n\n// Before: successful[0].data was User | null (no narrowing)\n// After: TypeScript now narrows better through filter\n\n// The PROBLEM: downstream code that was doing redundant null checks\n// now gets flagged because TS knows the value can't be null\nsuccessful.forEach((r) => {\n if (r.data === null) {\n // TS 5.5: Error! This condition will always be false\n // Because .filter() already narrowed it\n return;\n }\n processUser(r.data);\n});\n```\n\nThe fix is usually to remove the now-redundant null checks. But if you have a large codebase, this means dozens of changes. A practical approach: fix them gradually with ` @ts-expect-error ` comments and a lint rule to clean them up over time."},{"heading":"Isolated declarations: monorepo build speed revolution","level":2,"url":"https://lucioduran.com/blog/typescript-5-5-type-predicates-inference#isolated-declarations-monorepo-build-speed-revolution","content":"This is the feature that matters most for large teams. ` isolatedDeclarations ` is a new compiler option that requires exported functions and values to have explicit return type annotations:\n\n```typescript\n// tsconfig.json\n{\n \"compilerOptions\": {\n \"isolatedDeclarations\": true,\n \"declaration\": true\n }\n}\n\n// WITHOUT isolatedDeclarations (implicit return types are fine)\nexport function add(a: number, b: number) {\n return a + b; // Return type inferred as: number\n}\n\n// WITH isolatedDeclarations (explicit return type required)\nexport function add(a: number, b: number): number {\n return a + b;\n}\n\n// Error without explicit return type:\nexport function processData(input: string) {\n // ^^^ Error!\n // Exported function must have an explicit return type annotation\n // with --isolatedDeclarations\n const parsed = JSON.parse(input);\n return { ...parsed, processed: true };\n}\n\n// Fix: add explicit return type\ninterface ProcessedData {\n processed: true;\n [key: string]: unknown;\n}\n\nexport function processData(input: string): ProcessedData {\n const parsed = JSON.parse(input);\n return { ...parsed, processed: true };\n}\n```\n\nWhy does this matter? Because with explicit types on all exports, a tool can generate ` .d.ts ` files for a package **without type-checking the entire package**. This means:\n\n1. **Parallel .d.ts generation**: Each package in your monorepo can generate declarations independently\n2. **Incremental builds**: Only regenerate declarations for packages that changed\n3. **Third-party tools**: Tools like ` oxc `, ` swc `, and ` esbuild ` can generate ` .d.ts ` files without running the full TypeScript compiler\n\nFor a monorepo with 23 packages, this cuts ` tsc --build ` time from 94 seconds to 31 seconds. The TypeScript team reports even larger improvements on bigger monorepos."},{"heading":"Gradual adoption strategy","level":3,"url":"https://lucioduran.com/blog/typescript-5-5-type-predicates-inference#gradual-adoption-strategy","content":"You don't need to enable it everywhere at once:\n\n```json\n// packages/shared-utils/tsconfig.json (leaf package, start here)\n{\n \"extends\": \"../../tsconfig.base.json\",\n \"compilerOptions\": {\n \"isolatedDeclarations\": true\n }\n}\n\n// packages/api-client/tsconfig.json (depends on shared-utils)\n// Enable AFTER shared-utils is migrated\n{\n \"extends\": \"../../tsconfig.base.json\",\n \"compilerOptions\": {\n \"isolatedDeclarations\": true\n }\n}\n\n// apps/web/tsconfig.json (app, no need to enable)\n// Apps typically don't export .d.ts files\n{\n \"extends\": \"../../tsconfig.base.json\"\n}\n```\n\nStart with your leaf packages (no internal dependencies), then work inward. App packages don't need it because they don't produce declarations.\n\nThe ` --declaration --isolatedDeclarations ` fix-it hint is your friend. Run ` tsc ` with these flags and it'll tell you exactly which functions need explicit annotations. We automated this with a script:\n\n```bash\n# Find all missing return types\nnpx tsc --noEmit --isolatedDeclarations 2>&1 | \\\n grep \"TS9007\" | \\\n awk -F'[:(]' '{print $1 \":\" $2}' | \\\n sort -u\n```"},{"heading":"Config extends: the ${configDir} template variable","level":2,"url":"https://lucioduran.com/blog/typescript-5-5-type-predicates-inference#config-extends-the-configdir-template-variable","content":"TypeScript 5.5 introduces ` ${configDir} ` as a template variable in tsconfig paths. This solves a long-standing pain point in monorepos:\n\n```json\n// BEFORE: paths are relative to the extending config's location\n// tsconfig.base.json\n{\n \"compilerOptions\": {\n \"outDir\": \"./dist\", // Relative to... which file?\n \"rootDir\": \"./src\", // Changes meaning when extended\n \"paths\": {\n \"@/*\": [\"./src/*\"] // Breaks in nested configs\n }\n }\n}\n\n// AFTER: ${configDir} always refers to the directory containing the file\n// tsconfig.base.json\n{\n \"compilerOptions\": {\n \"outDir\": \"${configDir}/dist\",\n \"rootDir\": \"${configDir}/src\",\n \"paths\": {\n \"@/*\": [\"${configDir}/src/*\"]\n }\n }\n}\n```\n\nNow when ` packages/api/tsconfig.json ` extends ` ../../tsconfig.base.json `, ` ${configDir} ` resolves to ` packages/api/ `, not the root. No more broken paths.\n\nThis is especially useful for the pattern where you have different configs for different concerns:\n\n```json\n// tsconfig.base.json, shared compiler options\n{\n \"compilerOptions\": {\n \"target\": \"ES2022\",\n \"module\": \"NodeNext\",\n \"strict\": true,\n \"outDir\": \"${configDir}/dist\",\n \"rootDir\": \"${configDir}/src\",\n \"declarationDir\": \"${configDir}/dist/types\"\n }\n}\n\n// tsconfig.test.json, test configuration\n{\n \"extends\": \"./tsconfig.base.json\",\n \"compilerOptions\": {\n \"rootDir\": \"${configDir}\", // Include test files\n \"types\": [\"vitest/globals\"]\n },\n \"include\": [\"${configDir}/src/**/*\", \"${configDir}/tests/**/*\"]\n}\n```"},{"heading":"Regex type narrowing","level":2,"url":"https://lucioduran.com/blog/typescript-5-5-type-predicates-inference#regex-type-narrowing","content":"TypeScript 5.5 adds type narrowing through regular expression ` .test() ` and ` .match() ` calls. This is more useful than it sounds:\n\n```typescript\nfunction processInput(value: string | number) {\n if (typeof value === \"string\") {\n // value is string here\n\n if (/^\\d+$/.test(value)) {\n // value is still string, but TS now knows it matched the regex\n // This helps with overload resolution and template literal types\n const num = parseInt(value, 10); // TS knows this is safe\n }\n }\n}\n\n// More practical: discriminating string formats\ntype EmailString = `${string}@${string}.${string}`;\ntype UrlString = `https://${string}`;\n\nfunction categorize(input: string): \"email\" | \"url\" | \"other\" {\n if (/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(input)) {\n return \"email\";\n }\n if (/^https:\\/\\//.test(input)) {\n return \"url\";\n }\n return \"other\";\n}\n\n// Where it DOESN'T work\nconst result = \"hello\".search(/pattern/);\n// search returns a number, not a boolean, no narrowing\n\nconst replaced = \"hello\".replace(/h/, \"H\");\n// replace returns a string, no narrowing\n```"},{"heading":"Real-world patterns that got better","level":2,"url":"https://lucioduran.com/blog/typescript-5-5-type-predicates-inference#real-world-patterns-that-got-better","content":"Here are some patterns from a real codebase that improved with 5.5:"},{"heading":"Discriminated union filtering","level":3,"url":"https://lucioduran.com/blog/typescript-5-5-type-predicates-inference#discriminated-union-filtering","content":"```typescript\ntype Event =\n | { type: \"click\"; x: number; y: number }\n | { type: \"keypress\"; key: string }\n | { type: \"scroll\"; delta: number };\n\nconst events: Event[] = getEvents();\n\n// Before TS 5.5: needed explicit predicate\nconst clicks = events.filter(\n (e): e is Extract<Event, { type: \"click\" }> => e.type === \"click\"\n);\n\n// After TS 5.5: predicate is inferred\nconst clicks = events.filter((e) => {\n return e.type === \"click\";\n});\n// Type: { type: \"click\"; x: number; y: number }[]\n```"},{"heading":"Array.from with map and filter","level":3,"url":"https://lucioduran.com/blog/typescript-5-5-type-predicates-inference#arrayfrom-with-map-and-filter","content":"```typescript\n// Transform and filter in one pass\nconst validEntries = Array.from(formData.entries())\n .map(([key, value]) => {\n if (typeof value !== \"string\") return null;\n if (value.trim() === \"\") return null;\n return { key, value: value.trim() };\n })\n .filter((entry) => {\n return entry !== null;\n });\n// TS 5.5: { key: string; value: string }[]\n// TS 5.4: ({ key: string; value: string } | null)[]\n```"},{"heading":"Zod-like schema validation","level":3,"url":"https://lucioduran.com/blog/typescript-5-5-type-predicates-inference#zod-like-schema-validation","content":"```typescript\n// Type-safe validation pipeline\nfunction createValidator<T>(schema: {\n validate: (input: unknown) => input is T;\n}) {\n return {\n parse(data: unknown): T {\n if (schema.validate(data)) {\n return data; // Properly narrowed!\n }\n throw new Error(\"Validation failed\");\n },\n // In 5.5, this filter works correctly\n filterValid(items: unknown[]): T[] {\n return items.filter((item) => {\n return schema.validate(item);\n });\n },\n };\n}\n```"},{"heading":"The performance numbers","level":2,"url":"https://lucioduran.com/blog/typescript-5-5-type-predicates-inference#the-performance-numbers","content":"Real numbers from a monorepo migration (23 packages, ~180K lines of TypeScript):\n\n| Metric | TS 5.4 | TS 5.5 | TS 5.5 + isolatedDecl |\n| --- | --- | --- | --- |\n| Full build | 94s | 89s | 31s |\n| Incremental (1 file) | 12s | 11s | 3.2s |\n| Declaration gen | 28s | 26s | 8.1s |\n| IDE responsiveness | ~800ms | ~650ms | ~650ms |\n\nThe ` isolatedDeclarations ` improvement is the headline number. Going from 94s to 31s for a full build is the difference between \"grab a coffee\" and \"it's already done.\""},{"heading":"Known upgrade considerations","level":2,"url":"https://lucioduran.com/blog/typescript-5-5-type-predicates-inference#known-upgrade-considerations","content":"A few things worth knowing before upgrading:\n\n1. **Inferred predicates can widen your types**: If a function previously returned ` boolean ` and now returns ` x is Foo `, code that depended on the boolean return value might behave differently with control flow analysis.\n2. **isolatedDeclarations + declaration maps**: If you use ` declarationMap `, the maps now point to the original source even without full type checking. Make sure your source maps configuration is consistent.\n3. **The ` satisfies ` + inferred predicates interaction**: ` satisfies ` doesn't preserve the inferred predicate. If you have ` const isString = ((x: unknown) => typeof x === 'string') satisfies (x: unknown) => boolean `, the predicate is lost.\n4. **Performance regression with deeply nested conditional types**: There is a known case where TS 5.5's new type inference causes a 10x slowdown on a specific file with deeply nested ` Omit<Pick<Partial<...>>> ` chains. The fix was to simplify the types.\n\nTypeScript 5.5 is a genuinely great release. Inferred type predicates fix one of the most common papercuts in the language. Isolated declarations unlock real build speed improvements for monorepos. And the config improvements make shared configurations actually work. Upgrade carefully, expect some type errors that are actually bug fixes, and enjoy the ride."},{"heading":"Frequently asked questions","level":2,"url":"https://lucioduran.com/blog/typescript-5-5-type-predicates-inference#questions","content":""},{"heading":"What are inferred type predicates in TypeScript 5.5?","level":3,"url":"https://lucioduran.com/blog/typescript-5-5-type-predicates-inference#question-1","content":"TypeScript 5.5 can automatically infer that a function like (x) =\\> x !== null acts as a type predicate (x is NonNullable\\<T\\>), without you having to write the 'is' annotation manually. This makes .filter(Boolean) and similar patterns work correctly with type narrowing."},{"heading":"What is isolatedDeclarations and why should I care?","level":3,"url":"https://lucioduran.com/blog/typescript-5-5-type-predicates-inference#question-2","content":"isolatedDeclarations requires that all exported functions and values have explicit type annotations, enabling declaration file generation without full type-checking. This unlocks parallel and incremental .d.ts generation, dramatically speeding up monorepo builds."},{"heading":"Does TypeScript 5.5 break existing code?","level":3,"url":"https://lucioduran.com/blog/typescript-5-5-type-predicates-inference#question-3","content":"Potentially yes. Inferred type predicates can change the inferred types in existing .filter() calls, which might reveal previously hidden type errors. The fix is usually correct, the old behavior was technically wrong, but it can be surprising."},{"heading":"Tips","level":2,"url":"https://lucioduran.com/blog/typescript-5-5-type-predicates-inference#article-tips","content":"1. **Tip:** Inferred type predicates only work with ` return ` statements, arrow functions with expression bodies like ` (x) => x !== null ` won't infer predicates. Wrap them in braces.\n2. **Tip:** Enable ` isolatedDeclarations ` gradually, start with your leaf packages in a monorepo and work inward. It requires explicit return types on exported functions.\n3. **Tip:** The ` configDir ` template variable in tsconfig extends significantly improves workflow in monorepos, use ` ${configDir}/src ` instead of relative paths that break when configs are nested.\n4. **Tip:** Regex type narrowing only works with ` .test() ` and ` .match() `, ` .search() ` and ` .replace() ` don't narrow types. This catches people off guard constantly.\n\n- typescript\n- type-predicates\n- isolated-declarations\n- type-narrowing\n- javascript\n- dx\n- compiler\n- type-system\n\n[Back to Blog](<https://lucioduran.com/blog>)"}],"links":[{"text":"Español","url":"https://lucioduran.com/es/blog/typescript-5-5-type-predicates-inference"},{"text":"TypeScript","url":"https://www.typescriptlang.org/"},{"text":"Back to Blog","url":"https://lucioduran.com/blog"}],"structuredData":[{"@context":"https://schema.org","@graph":[{"@type":"BlogPosting","@id":"https://lucioduran.com/blog/typescript-5-5-type-predicates-inference#article","mainEntityOfPage":{"@type":"WebPage","@id":"https://lucioduran.com/blog/typescript-5-5-type-predicates-inference"},"headline":"TypeScript 5.5: Inferred Type Predicates, Isolated Declarations, and Real-World Gotchas","description":"Analysis of TypeScript 5.5's biggest features: inferred type predicates that actually work, isolated declarations for faster builds, config extends improvements, regex type narrowing, and patterns that will bite you.","datePublished":"2025-12-24","author":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"publisher":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"image":"https://lucioduran.com/favicons/og-default.png","url":"https://lucioduran.com/blog/typescript-5-5-type-predicates-inference","inLanguage":"en","isPartOf":{"@id":"https://lucioduran.com/blog#blog"},"articleSection":"TypeScript","keywords":"typescript, type-predicates, isolated-declarations, type-narrowing, javascript, dx, compiler, type-system","wordCount":1821,"timeRequired":"PT10M","workTranslation":[{"@type":"BlogPosting","inLanguage":"es","url":"https://lucioduran.com/es/blog/typescript-5-5-type-predicates-inference"}]},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://lucioduran.com/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://lucioduran.com/blog"},{"@type":"ListItem","position":3,"name":"TypeScript 5.5: Inferred Type Predicates, Isolated Declarations, and Real-World Gotchas","item":"https://lucioduran.com/blog/typescript-5-5-type-predicates-inference"}]},{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"What are inferred type predicates in TypeScript 5.5?","acceptedAnswer":{"@type":"Answer","text":"TypeScript 5.5 can automatically infer that a function like (x) => x !== null acts as a type predicate (x is NonNullable<T>), without you having to write the 'is' annotation manually. This makes .filter(Boolean) and similar patterns work correctly with type narrowing."}},{"@type":"Question","name":"What is isolatedDeclarations and why should I care?","acceptedAnswer":{"@type":"Answer","text":"isolatedDeclarations requires that all exported functions and values have explicit type annotations, enabling declaration file generation without full type-checking. This unlocks parallel and incremental .d.ts generation, dramatically speeding up monorepo builds."}},{"@type":"Question","name":"Does TypeScript 5.5 break existing code?","acceptedAnswer":{"@type":"Answer","text":"Potentially yes. Inferred type predicates can change the inferred types in existing .filter() calls, which might reveal previously hidden type errors. The fix is usually correct, the old behavior was technically wrong, but it can be surprising."}}]}]}],"contentHash":"d98e67163cf4bdf3e7c2e4683a33ec8ec4dd54184a96bc1cee5155a18165772d","provenance":{"method":"Deterministic serialization of the server-rendered main element","source":"https://lucioduran.com/blog/typescript-5-5-type-predicates-inference","hashAlgorithm":"sha256","hashScope":"JSON document fields before contentHash and provenance","sourceHTMLBytes":71891,"contentBytes":15013}}
