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:

// 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 5.5, the compiler automatically infers that (v) => v !== null is a type predicate. No annotation needed:

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

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

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:

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

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

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

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

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

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

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

// 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

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