<!-- Canonical: https://lucioduran.com/blog/typescript-5-5-type-predicates-inference
Language: en
Content-SHA256: d98e67163cf4bdf3e7c2e4683a33ec8ec4dd54184a96bc1cee5155a18165772d
-->

TypeScript

# TypeScript 5.5: Inferred Type Predicates, Isolated Declarations, and Real-World Gotchas

2025-12-2410 min read

Lucio Durán
Engineering Manager & AI Solutions Architect

Also available in: [Español](<https://lucioduran.com/es/blog/typescript-5-5-type-predicates-inference>)

## Inferred type predicates: the biggest quality-of-life improvement in years

This is arguably the most anticipated quality-of-life improvement for TypeScript developers. Consider the most common pattern in any codebase:

```typescript
// Before TS 5.5
const values: (string | null)[] = ["hello", null, "world", null, "!"];

// This SHOULD give us string[], but it doesn't
const filtered = values.filter((v) => v !== null);
// Type: (string | null)[], WRONG! We just filtered out nulls!

// The workaround was this ugly mess
const filteredCorrect = values.filter((v): v is string => v !== null);
// Type: string[], Correct, but annoying boilerplate
```

In [TypeScript](<https://www.typescriptlang.org/>) 5.5, the compiler automatically infers that ` (v) => v !== null ` is a type predicate. No annotation needed:

```typescript
// TypeScript 5.5
const values: (string | null)[] = ["hello", null, "world", null, "!"];

const filtered = values.filter((v) => v !== null);
// Type: string[], FINALLY!

// Works with more complex patterns too
interface User {
 id: string;
 name: string;
 email: string | null;
 deletedAt: Date | null;
}

const users: User[] = getUsers();

// Filter active users with email
const activeWithEmail = users.filter((u) => {
 return u.deletedAt === null && u.email !== null;
});
// Type is still User[], not narrowed on properties
// BUT this works:

function hasEmail(user: User): user is User & { email: string } {
 return user.email !== null;
}
// This predicate IS now inferred if you use it in a filter
```

### Where the inference works (and where it doesn't)

The inference has specific rules you need to know:

```typescript
// WORKS: explicit return statement
const isString = (x: unknown) => {
 return typeof x === "string";
};
// Inferred: (x: unknown) => x is string

// DOESN'T WORK: expression body (no braces)
const isStringExpr = (x: unknown) => typeof x === "string";
// Inferred: (x: unknown) => boolean, NO predicate!
// This is because expression bodies could have side effects
// that make predicate inference unsound

// WORKS: null/undefined checks
const isDefined = <T>(x: T | null | undefined) => {
 return x !== null && x !== undefined;
};
// Inferred: (x: T | null | undefined) => x is T

// WORKS: instanceof checks
const isDate = (x: unknown) => {
 return x instanceof Date;
};
// Inferred: (x: unknown) => x is Date

// WORKS: property checks
const hasName = (x: object) => {
 return "name" in x;
};
// Inferred: (x: object) => x is object & Record<"name", unknown>

// DOESN'T WORK: complex conditions with side effects
let sideEffect = false;
const isBad = (x: unknown) => {
 sideEffect = true; // Side effect prevents inference
 return typeof x === "string";
};
// Inferred: (x: unknown) => boolean
```

### The migration gotcha

Here is a common migration pitfall. Consider code like this:

```typescript
interface ApiResponse {
 data: User | null;
 error: string | null;
}

const responses: ApiResponse[] = await Promise.all(requests);

// Before TS 5.5: type was ApiResponse[]
// After TS 5.5: type is still ApiResponse[], BUT...
const successful = responses.filter((r) => r.data !== null);

// Before: successful[0].data was User | null (no narrowing)
// After: TypeScript now narrows better through filter

// The PROBLEM: downstream code that was doing redundant null checks
// now gets flagged because TS knows the value can't be null
successful.forEach((r) => {
 if (r.data === null) {
 // TS 5.5: Error! This condition will always be false
 // Because .filter() already narrowed it
 return;
 }
 processUser(r.data);
});
```

The 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.

## Isolated declarations: monorepo build speed revolution

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:

```typescript
// tsconfig.json
{
 "compilerOptions": {
 "isolatedDeclarations": true,
 "declaration": true
 }
}

// WITHOUT isolatedDeclarations (implicit return types are fine)
export function add(a: number, b: number) {
 return a + b; // Return type inferred as: number
}

// WITH isolatedDeclarations (explicit return type required)
export function add(a: number, b: number): number {
 return a + b;
}

// Error without explicit return type:
export function processData(input: string) {
 // ^^^ Error!
 // Exported function must have an explicit return type annotation
 // with --isolatedDeclarations
 const parsed = JSON.parse(input);
 return { ...parsed, processed: true };
}

// Fix: add explicit return type
interface ProcessedData {
 processed: true;
 [key: string]: unknown;
}

export function processData(input: string): ProcessedData {
 const parsed = JSON.parse(input);
 return { ...parsed, processed: true };
}
```

Why 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:

1. **Parallel .d.ts generation**: Each package in your monorepo can generate declarations independently
2. **Incremental builds**: Only regenerate declarations for packages that changed
3. **Third-party tools**: Tools like ` oxc `, ` swc `, and ` esbuild ` can generate ` .d.ts ` files without running the full TypeScript compiler

For 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.

### Gradual adoption strategy

You don't need to enable it everywhere at once:

```json
// packages/shared-utils/tsconfig.json (leaf package, start here)
{
 "extends": "../../tsconfig.base.json",
 "compilerOptions": {
 "isolatedDeclarations": true
 }
}

// packages/api-client/tsconfig.json (depends on shared-utils)
// Enable AFTER shared-utils is migrated
{
 "extends": "../../tsconfig.base.json",
 "compilerOptions": {
 "isolatedDeclarations": true
 }
}

// apps/web/tsconfig.json (app, no need to enable)
// Apps typically don't export .d.ts files
{
 "extends": "../../tsconfig.base.json"
}
```

Start with your leaf packages (no internal dependencies), then work inward. App packages don't need it because they don't produce declarations.

The ` --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:

```bash
# Find all missing return types
npx tsc --noEmit --isolatedDeclarations 2>&1 | \
 grep "TS9007" | \
 awk -F'[:(]' '{print $1 ":" $2}' | \
 sort -u
```

## Config extends: the ${configDir} template variable

TypeScript 5.5 introduces ` ${configDir} ` as a template variable in tsconfig paths. This solves a long-standing pain point in monorepos:

```json
// BEFORE: paths are relative to the extending config's location
// tsconfig.base.json
{
 "compilerOptions": {
 "outDir": "./dist", // Relative to... which file?
 "rootDir": "./src", // Changes meaning when extended
 "paths": {
 "@/*": ["./src/*"] // Breaks in nested configs
 }
 }
}

// AFTER: ${configDir} always refers to the directory containing the file
// tsconfig.base.json
{
 "compilerOptions": {
 "outDir": "${configDir}/dist",
 "rootDir": "${configDir}/src",
 "paths": {
 "@/*": ["${configDir}/src/*"]
 }
 }
}
```

Now when ` packages/api/tsconfig.json ` extends ` ../../tsconfig.base.json `, ` ${configDir} ` resolves to ` packages/api/ `, not the root. No more broken paths.

This is especially useful for the pattern where you have different configs for different concerns:

```json
// tsconfig.base.json, shared compiler options
{
 "compilerOptions": {
 "target": "ES2022",
 "module": "NodeNext",
 "strict": true,
 "outDir": "${configDir}/dist",
 "rootDir": "${configDir}/src",
 "declarationDir": "${configDir}/dist/types"
 }
}

// tsconfig.test.json, test configuration
{
 "extends": "./tsconfig.base.json",
 "compilerOptions": {
 "rootDir": "${configDir}", // Include test files
 "types": ["vitest/globals"]
 },
 "include": ["${configDir}/src/**/*", "${configDir}/tests/**/*"]
}
```

## Regex type narrowing

TypeScript 5.5 adds type narrowing through regular expression ` .test() ` and ` .match() ` calls. This is more useful than it sounds:

```typescript
function processInput(value: string | number) {
 if (typeof value === "string") {
 // value is string here

 if (/^\d+$/.test(value)) {
 // value is still string, but TS now knows it matched the regex
 // This helps with overload resolution and template literal types
 const num = parseInt(value, 10); // TS knows this is safe
 }
 }
}

// More practical: discriminating string formats
type EmailString = `${string}@${string}.${string}`;
type UrlString = `https://${string}`;

function categorize(input: string): "email" | "url" | "other" {
 if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input)) {
 return "email";
 }
 if (/^https:\/\//.test(input)) {
 return "url";
 }
 return "other";
}

// Where it DOESN'T work
const result = "hello".search(/pattern/);
// search returns a number, not a boolean, no narrowing

const replaced = "hello".replace(/h/, "H");
// replace returns a string, no narrowing
```

## Real-world patterns that got better

Here are some patterns from a real codebase that improved with 5.5:

### Discriminated union filtering

```typescript
type Event =
 | { type: "click"; x: number; y: number }
 | { type: "keypress"; key: string }
 | { type: "scroll"; delta: number };

const events: Event[] = getEvents();

// Before TS 5.5: needed explicit predicate
const clicks = events.filter(
 (e): e is Extract<Event, { type: "click" }> => e.type === "click"
);

// After TS 5.5: predicate is inferred
const clicks = events.filter((e) => {
 return e.type === "click";
});
// Type: { type: "click"; x: number; y: number }[]
```

### Array.from with map and filter

```typescript
// Transform and filter in one pass
const validEntries = Array.from(formData.entries())
 .map(([key, value]) => {
 if (typeof value !== "string") return null;
 if (value.trim() === "") return null;
 return { key, value: value.trim() };
 })
 .filter((entry) => {
 return entry !== null;
 });
// TS 5.5: { key: string; value: string }[]
// TS 5.4: ({ key: string; value: string } | null)[]
```

### Zod-like schema validation

```typescript
// Type-safe validation pipeline
function createValidator<T>(schema: {
 validate: (input: unknown) => input is T;
}) {
 return {
 parse(data: unknown): T {
 if (schema.validate(data)) {
 return data; // Properly narrowed!
 }
 throw new Error("Validation failed");
 },
 // In 5.5, this filter works correctly
 filterValid(items: unknown[]): T[] {
 return items.filter((item) => {
 return schema.validate(item);
 });
 },
 };
}
```

## The performance numbers

Real numbers from a monorepo migration (23 packages, ~180K lines of TypeScript):

| Metric | TS 5.4 | TS 5.5 | TS 5.5 + isolatedDecl |
| --- | --- | --- | --- |
| Full build | 94s | 89s | 31s |
| Incremental (1 file) | 12s | 11s | 3.2s |
| Declaration gen | 28s | 26s | 8.1s |
| IDE responsiveness | ~800ms | ~650ms | ~650ms |

The ` 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."

## Known upgrade considerations

A few things worth knowing before upgrading:

1. **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.
2. **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.
3. **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.
4. **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.

TypeScript 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.

## Frequently asked questions

### What are inferred type predicates in TypeScript 5.5?

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.

### What is isolatedDeclarations and why should I care?

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.

### Does TypeScript 5.5 break existing code?

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.

## Tips

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.
2. **Tip:** Enable ` isolatedDeclarations ` gradually, start with your leaf packages in a monorepo and work inward. It requires explicit return types on exported functions.
3. **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.
4. **Tip:** Regex type narrowing only works with ` .test() ` and ` .match() `, ` .search() ` and ` .replace() ` don't narrow types. This catches people off guard constantly.

- typescript
- type-predicates
- isolated-declarations
- type-narrowing
- javascript
- dx
- compiler
- type-system

[Back to Blog](<https://lucioduran.com/blog>)
