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:
- Parallel .d.ts generation: Each package in your monorepo can generate declarations independently
- Incremental builds: Only regenerate declarations for packages that changed
- Third-party tools: Tools like
oxc,swc, andesbuildcan generate.d.tsfiles 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:
-
Inferred predicates can widen your types: If a function previously returned
booleanand now returnsx is Foo, code that depended on the boolean return value might behave differently with control flow analysis. -
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. -
The
satisfies+ inferred predicates interaction:satisfiesdoesn't preserve the inferred predicate. If you haveconst isString = ((x: unknown) => typeof x === 'string') satisfies (x: unknown) => boolean, the predicate is lost. -
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.