TypeScript 5.5 focuses on two things developers actually feel day to day: removing boilerplate that the compiler should have been able to work out for itself, and preserving type information that used to get thrown away. Inferred type predicates and const type parameters are the headline features, and both eliminate categories of code you previously had to write by hand.
The theme of this release is inference. Type predicates you used to annotate manually are now derived from the function body, and literal types you used to preserve with as const can be preserved by the function signature instead. Less code, same safety.
1. Inferred Type Predicates
Before 5.5, a function that narrowed a type had to say so explicitly with a value is T return annotation. TypeScript now infers this when the function body makes the narrowing obvious.
Basic Example
// Before TypeScript 5.5 — explicit type predicate required
function isString(value: unknown): value is string {
return typeof value === 'string';
}
function isNumber(value: unknown): value is number {
return typeof value === 'number';
}
function isError(value: unknown): value is Error {
return value instanceof Error;
}
// TypeScript 5.5 — predicates inferred from the implementation
function isString(value: unknown) {
return typeof value === 'string';
}
function isNumber(value: unknown) {
return typeof value === 'number';
}
function isError(value: unknown) {
return value instanceof Error;
}
// Narrowing works the same way at the call site
function processValue(value: string | number) {
if (isString(value)) {
console.log(value.toUpperCase()); // value: string
} else {
console.log(value.toFixed(2)); // value: number
}
}
TypeScript only infers a predicate when it can prove the narrowing. The function must have no explicit return type, must return a boolean expression, must not mutate its parameters, and the parameter must not be reassigned. Complex validators that build up a result across several statements generally won't qualify — which is why the explicit form still exists.
API Response Validation
The pattern shows up constantly in discriminated-union handling:
interface SuccessResponse<T> {
data: T;
status: 'success';
}
interface ErrorResponse {
error: string;
status: 'error';
}
type APIResponse<T> = SuccessResponse<T> | ErrorResponse;
// Before 5.5 — the predicate had to be spelled out
function isSuccessResponse<T>(
response: APIResponse<T>
): response is SuccessResponse<T> {
return response.status === 'success';
}
// 5.5 — inferred
function isSuccessResponse<T>(response: APIResponse<T>) {
return response.status === 'success';
}
async function fetchProduct(id: string) {
const response = await fetch(`/api/products/${id}`);
const data: APIResponse<Product> = await response.json();
if (isSuccessResponse(data)) {
return data.data; // Narrowed to SuccessResponse<Product>
} else {
throw new Error(data.error); // Narrowed to ErrorResponse
}
}
Array Filtering
This is where inferred predicates pay off most, because filter callbacks were the most common place people wrote inline predicates:
// Before 5.5 — inline predicate annotation
const values: (string | null | undefined)[] = ['a', null, 'b', undefined, 'c'];
const strings = values.filter((v): v is string => v != null);
// Type: string[]
// 5.5 — a plain, reusable helper narrows correctly
function isDefined<T>(value: T | null | undefined) {
return value != null;
}
const strings = values.filter(isDefined);
// Type: string[] — inferred
The practical win is that narrowing helpers become ordinary, reusable functions rather than one-off inline annotations copy-pasted around a codebase.
Because inference now applies to functions that previously returned plain boolean, upgrading can change types in code you didn't touch. That's usually an improvement — but it can surface pre-existing bugs as new errors. Expect some new diagnostics on a large codebase, and treat them as findings rather than regressions.
2. Const Type Parameters
The const modifier on a type parameter preserves literal types through a function call, without the caller having to write as const.
Basic Example
// Without const — types get widened
function createConfig<T>(options: T) {
return options;
}
const config1 = createConfig({ mode: 'development', port: 3000 });
// Type: { mode: string, port: number } — literals lost
// With a const type parameter
function createConfig<const T>(options: T) {
return options;
}
const config2 = createConfig({ mode: 'development', port: 3000 });
// Type: { mode: 'development', port: 3000 } — literals preserved
The important part is who has to remember. Previously the caller had to write as const at every call site; now the library author declares it once and every caller benefits.
Type-Safe Routing
function createRouter<const T extends Record<string, string>>(routes: T) {
return {
routes,
navigate: (path: keyof T) => {
window.location.href = routes[path];
},
link: (path: keyof T) => routes[path],
};
}
const router = createRouter({
home: '/',
dashboard: '/dashboard',
profile: '/profile',
settings: '/settings',
'user-management': '/admin/users',
'billing': '/billing',
});
router.navigate('home'); // OK
router.navigate('dashboard'); // OK
router.navigate('unknown'); // Type error
// Autocomplete works, and the return type is the exact literal union
const profileUrl = router.link('profile');
API Endpoint Registry
function createAPI<const T extends Record<string, { method: string; path: string }>>(
endpoints: T
) {
return {
endpoints,
call: async <K extends keyof T>(endpoint: K, data?: unknown) => {
const config = endpoints[endpoint];
const response = await fetch(config.path, {
method: config.method,
body: JSON.stringify(data),
});
return response.json();
},
};
}
const api = createAPI({
getUser: { method: 'GET', path: '/api/users/:id' },
createUser: { method: 'POST', path: '/api/users' },
updateUser: { method: 'PUT', path: '/api/users/:id' },
deleteUser: { method: 'DELETE', path: '/api/users/:id' },
});
await api.call('getUser'); // OK
await api.call('invalidEndpoint'); // Type error
Configuration Objects
function createAppConfig<const T>(config: T) {
return {
...config,
get: <K extends keyof T>(key: K): T[K] => config[key],
};
}
const appConfig = createAppConfig({
apiUrl: 'https://api.example.com',
apiVersion: 'v2',
features: {
darkMode: true,
analytics: false,
},
limits: {
maxUploadSize: 10485760,
maxRequests: 100,
},
});
const url = appConfig.get('apiUrl');
// Type: 'https://api.example.com' — not string
const darkMode = appConfig.get('features').darkMode;
// Type: true — not boolean
const type parameters infer the narrowest possible type, which is exactly wrong for anything that changes. A createState<const T>(initial: T) will infer T as the literal initial value, and every subsequent set() becomes a type error. Use const for static configuration, route tables, and lookup maps — not for runtime data.
3. Improved Control Flow Analysis
TypeScript 5.5 narrows better in several situations that previously required an assertion or a redundant check.
Discriminated Unions
type AsyncState<T, E = Error> =
| { status: 'idle' }
| { status: 'loading'; startedAt: number }
| { status: 'success'; data: T; loadedAt: number }
| { status: 'error'; error: E; failedAt: number };
function handleAsyncState<T>(state: AsyncState<T>) {
if (state.status === 'success') {
// data and loadedAt exist here
const age = Date.now() - state.loadedAt;
return { data: state.data, age };
}
if (state.status === 'error') {
// error and failedAt exist here
logError(state.error, state.failedAt);
return null;
}
if (state.status === 'loading') {
// startedAt exists here
const duration = Date.now() - state.startedAt;
return { duration };
}
return null; // status is 'idle'
}
Discriminated unions remain the single most effective way to model state in TypeScript: they make impossible states unrepresentable, so data simply doesn't exist on a loading state rather than being an optional you have to guard.
Exhaustive Switch Statements
type Status = 'idle' | 'loading' | 'success' | 'error';
function handleState(status: Status): string {
switch (status) {
case 'idle':
return 'Waiting to start...';
case 'loading':
return 'Loading...';
case 'success':
return 'Done';
case 'error':
return 'Failed';
}
// No default needed — TypeScript knows every case is covered
}
Relying on the absence of a default clause is fragile. Adding a never assertion turns "someone added a variant and forgot to handle it" into a compile error rather than a silent undefined return:
default: {
const _exhaustive: never = status;
throw new Error(`Unhandled status: ${status}`);
}
4. Template Literal Types
type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type Resource = 'users' | 'posts' | 'comments';
type APIVersion = 'v1' | 'v2';
type APIEndpoint = `/${APIVersion}/${Resource}`;
// '/v1/users' | '/v1/posts' | '/v1/comments' | '/v2/users' | ...
function callAPI<T>(endpoint: APIEndpoint, method: HTTPMethod): Promise<T> {
return fetch(`https://api.example.com${endpoint}`, { method }).then(r => r.json());
}
await callAPI('/v2/users', 'GET'); // OK
await callAPI('/v3/users', 'GET'); // Error: v3 not in APIVersion
await callAPI('/v2/products', 'GET'); // Error: products not in Resource
// Design-system tokens
type CSSUnit = 'px' | 'rem' | 'em' | '%';
type Spacing = 0 | 4 | 8 | 12 | 16 | 20 | 24 | 32 | 40 | 48;
type SpacingValue = `${Spacing}${CSSUnit}`;
function margin(value: SpacingValue) {
return { margin: value };
}
margin('16px'); // OK
margin('15px'); // Error: 15 is not in Spacing
Template literal types multiply. Four methods times two versions times three resources is 24 members — fine. But cross a few larger unions and you can generate tens of thousands of members and watch compile times collapse. TypeScript enforces a hard cap on union size for exactly this reason. If a template type feels slow, it probably is.
5. The satisfies Operator
satisfies checks a value against a type without widening it to that type — you get validation and precise inference at once:
type Config = {
host: string;
port: number;
ssl: boolean;
features: {
logging: boolean;
caching: boolean;
monitoring: boolean;
};
};
const config = {
host: 'localhost',
port: 8080,
ssl: true,
features: {
logging: true,
caching: false,
monitoring: true,
},
} satisfies Config;
// Validated against Config, but inference stays precise
config.features.logging; // boolean, and known to exist
config.host.toUpperCase(); // string methods available
// Typos are caught
const badConfig = {
host: 'localhost',
port: 8080,
ssl: true,
features: {
logging: true,
cacheing: false, // Error: typo caught
},
} satisfies Config;
The distinction from an annotation matters: const config: Config = {...} would widen every property to its declared type, losing the literals. satisfies keeps both.
6. Performance and Editor Improvements
TypeScript 5.5 includes compiler and language-service optimizations, along with a smaller tsc output and faster editor operations. The release notes document the specific work, including improvements to how type instantiations are cached and a monomorphized node representation.
How much any of this helps depends entirely on your codebase — a project dominated by deeply recursive conditional types will see different results from one that's mostly straightforward interfaces. Rather than trusting a number from someone else's project, measure your own:
# Overall compile time
time npx tsc --noEmit
# Where the time actually goes
npx tsc --noEmit --extendedDiagnostics
# Detailed trace, viewable in chrome://tracing
npx tsc --noEmit --generateTrace trace
--generateTrace is the one worth knowing about. It shows which files and which types dominate check time, which usually reveals that a small number of pathological types are responsible for most of the cost.
Migration Guide
Phase 1: Upgrade
npm install -D typescript@5.5
npm install -D @types/node@latest @types/react@latest
A reasonable baseline tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"noUncheckedIndexedAccess": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"incremental": true,
"declaration": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
Run your existing test suite first. The upgrade should be non-breaking for most projects, but inferred predicates can surface new errors in code that was previously under-typed.
Phase 2: Remove Redundant Type Predicates
Find candidates with a search for the is return annotation, then remove the annotation and confirm narrowing still works at the call sites:
// Good — let TypeScript infer
function isAdmin(user: User) {
return user.role === 'admin';
}
// Keep explicit for complex validators — inference won't cover this
function isValidUser(user: unknown): user is User {
return (
typeof user === 'object' &&
user !== null &&
'id' in user &&
'name' in user &&
typeof user.id === 'string' &&
typeof user.name === 'string'
);
}
There's no correctness benefit to stripping annotations from working code, and a mass automated removal risks silently changing behaviour where inference doesn't apply. Remove them opportunistically as you touch files, not in one big sweep.
Phase 3: Add const Type Parameters
Good candidates are functions whose callers currently write as const:
- Configuration builders
- Route definitions
- API endpoint registries
- State machine definitions
- Lookup tables
// Before
function createRouter<T extends Record<string, any>>(routes: T) {
return routes;
}
// After
function createRouter<const T extends Record<string, any>>(routes: T) {
return routes;
}
Phase 4: Enable Stricter Checks
Turn these on one at a time — each will produce its own wave of errors:
{
"compilerOptions": {
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noPropertyAccessFromIndexSignature": true
}
}
// noUncheckedIndexedAccess forces you to handle the missing case
const users: Record<string, User> = {};
const user = users['123']; // Type: User | undefined
if (user) {
console.log(user.name);
}
noUncheckedIndexedAccess is the highest-value of the three and also the noisiest — it's correct that an arbitrary key lookup might miss, but a large codebase will light up. Enable it on new code first if you need to.
Common Migration Issues
Type Predicate Removal Breaks Narrowing
Symptom: removing the annotation stops narrowing at the call site.
Cause: the function doesn't meet the inference conditions.
Fix: put the explicit predicate back. Inference is a convenience, not a mandate:
function isString(value: unknown): value is string {
return typeof value === 'string';
}
Const Type Parameter Too Restrictive
Symptom: a setter rejects every value.
Cause: const inferred the literal type of the initial value.
Fix: drop const for anything mutable:
function createState<T>(initial: T) {
let state = initial;
return {
get: () => state,
set: (newState: T) => { state = newState; },
};
}
Compilation Slower After Upgrade
Diagnosis: run npx tsc --noEmit --generateTrace trace and look for hot spots.
Common causes: circular dependencies between modules, deeply recursive conditional types, and very large unions.
// Slow — an enormous union
type AllRoutes = Route1 | Route2 | /* ...hundreds more... */;
// Faster — an index signature
type Routes = Record<string, RouteConfig>;
Build Performance Tips
Project references let a monorepo type-check packages independently and cache the results:
// packages/core/tsconfig.json
{
"compilerOptions": {
"composite": true,
"declaration": true,
"outDir": "dist"
}
}
// packages/app/tsconfig.json
{
"compilerOptions": { "composite": true },
"references": [{ "path": "../core" }]
}
Incremental builds persist type information between runs:
{
"compilerOptions": {
"incremental": true,
"tsBuildInfoFile": ".tsbuildinfo"
}
}
skipLibCheck skips type-checking of .d.ts files. It's widely used and usually safe, but it does mean genuine conflicts between your dependencies' type definitions will go unnoticed.
Resources
- TypeScript 5.5 Release Notes — the authoritative list of what changed
- TypeScript Handbook
- TypeScript Playground — the fastest way to check whether inference applies to a given function
- TypeScript Performance Wiki
- DefinitelyTyped
Our Take
TypeScript 5.5 is a low-risk upgrade with a genuine payoff. Inferred type predicates and const type parameters both remove ceremony that existed only because the compiler couldn't previously work something out — and neither requires you to rewrite anything to benefit.
The pragmatic approach: upgrade, run your tests, and investigate any new errors as findings rather than annoyances. Add const to your configuration and route builders, where it costs one keyword and improves every call site. Leave existing type predicates alone unless you're already editing the file. And if you want to know whether the performance work helps your project, run --generateTrace on your own codebase — that number is the only one that matters.
For a broader look at why teams adopt TypeScript in the first place, see our TypeScript vs JavaScript guide.