Spent most of last winter doing something I should have done a year earlier: actually reading the TypeScript 5.x changelogs. Not skimming the headlines — reading them, then dropping each feature into a scratch project to see how it actually behaved. Our codebase sits at around 180k lines — a team of seven, a mix of Node.js inference services and React front-ends — and we'd been on TypeScript 5.x for over a year without meaningfully adopting anything new. We'd bumped the package version, confirmed the build didn't break, moved on.
What I found: maybe six features that genuinely changed how I write TypeScript, and a longer tail of things that are technically interesting but haven't touched my day-to-day work. This isn't a changelog recap. It's what actually earned its place.
using Declarations Fixed a Leak I'd Been Ignoring for Eight Months
The explicit resource management proposal — using and await using — landed in TypeScript 5.2, and I'm genuinely annoyed it took me this long to use it. The thing that finally pushed me to look: a slow memory leak in one of our LLM inference services I'd been deferring for months.
We were pooling inference sessions, and somewhere in the request-handling code, sessions weren't always being released. The try/finally blocks were there — mostly. One code path through a batch endpoint was missing the cleanup call. The session sat there, held in memory, until the process restarted. I pushed a fix on a Friday afternoon after tracing it for two hours, and I thought: this is the kind of bug that shouldn't be possible.
The old pattern:
// The before: try/finally that's correct until someone adds a code path
async function runBatchInference(prompts: string[]) {
const session = await pool.acquire();
try {
return await Promise.all(prompts.map(p => session.complete(p)));
} catch (err) {
logger.error('batch inference failed', err);
throw err;
// pool.release() was added here by a colleague — but not in the finally block
} finally {
await pool.release(session); // sometimes ran twice. sometimes not at all.
}
}
After implementing Symbol.asyncDispose on the session class:
class InferenceSession {
private released = false;
async complete(prompt: string): Promise<string> { /* ... */ }
async [Symbol.asyncDispose](): Promise<void> {
if (!this.released) {
await pool.release(this);
this.released = true;
}
}
}
async function runBatchInference(prompts: string[]) {
await using session = await pool.acquire();
// No try/finally. Disposal is guaranteed at scope exit,
// regardless of which path the function takes.
return Promise.all(prompts.map(p => session.complete(p)));
}
What surprised me was the disposal ordering. When you stack multiple using declarations in the same scope, TypeScript disposes them in reverse order — last declared, first disposed, LIFO. I expected to have to verify this carefully and maybe work around edge cases. Nope. It just works the way you'd want it to if one resource depends on another.
If you manage any resource in TypeScript — database connections, file handles, WebSocket sessions, anything with a close() — implementing Symbol.dispose or Symbol.asyncDispose and switching to using is the most immediately practical change in all of 5.x.
Inferred Type Predicates Deleted About 300 Lines of Manual Guards
Before TypeScript 5.5, getting .filter() to actually narrow a type required an explicit type predicate function. We had a file of them: isNonNull, isLoaded, isSuccessResponse, isAPIError. About 300 lines across two utility modules, and someone would add a new one every couple of weeks. Every time we introduced a new union type, we'd forget to add the corresponding predicate, use the wrong one, or find out the type was wider than expected somewhere downstream.
TypeScript 5.5 introduced automatic inference of type predicates — when the compiler can determine from the function body that a value is being narrowed, it infers the value is T return type for you. The case that hit us hardest:
// Before 5.5 — you wrote this (correctly) every time
function isNonNull<T>(value: T | null | undefined): value is T {
return value != null;
}
const rawResults: (InferenceResult | null)[] = await runBatch(prompts);
const results = rawResults.filter(isNonNull); // InferenceResult[]
// After 5.5 — TypeScript infers the predicate from the inline callback
const results = rawResults.filter(r => r !== null);
// results is InferenceResult[], not (InferenceResult | null)[]
// No helper. No import. Just correct.
I deleted most of those utility files the same afternoon I confirmed this worked. Not all of them — there are still cases where the inference doesn't trigger. The rule of thumb I've built up: simple null and equality checks work reliably; anything with nested property access or custom logic still needs an explicit predicate.
One thing I noticed: this pairs well with typed AI SDK responses, where you're often getting back something like CompletionResult | RateLimitError | null from a batch call and need to split it into separate arrays. Used to be a predicate per type. Now it's an inline condition and the types just follow.
NoInfer Is Nine Characters and It Stopped a Real Bug
I'll be honest — I thought NoInfer<T> (added in 5.4) was a library-author concern when I first read about it. I was wrong. I ran into the problem it solves within two weeks.
The setup is — okay, let me back up a second. We have a config resolution function that looks up model configurations by key and falls back to a default. The default value was silently widening the inferred type, because TypeScript was using the fallback argument to infer T rather than the caller's intended type.
// Without NoInfer: the fallback widens T
function resolveConfig<T>(
registry: Map<string, T>,
key: string,
fallback: T // TypeScript infers T partly from here — the problem
): T {
return registry.get(key) ?? fallback;
}
// resolveConfig(myRegistry, 'gpt-4o', { temperature: 0.7 })
// infers T as { temperature: number }, not ModelConfig
// downstream code that expects ModelConfig now has no error
// With NoInfer: only the registry type informs T
function resolveConfig<T>(
registry: Map<string, T>,
key: string,
fallback: NoInfer<T> // can't influence T inference
): T {
return registry.get(key) ?? fallback;
}
That function had been silently widening types for months. I'm not 100% sure we ever shipped a production bug because of it — but we had tests passing on wider types than they should have been, and that's a bad place to be.
Reach for NoInfer when you write generic utilities with default or fallback parameters. You'll know when you need it because you'll see the inferred type being wider than you intended, and you'll wonder why. Then it'll click immediately.
verbatimModuleSyntax Is the Price of Admission for ESM in 2026
verbatimModuleSyntax shipped in 5.0, but I keep seeing teams who've skipped it — usually because enabling it immediately breaks forty files and no one wants to deal with that mid-sprint. I deferred it for months too. But now that Node.js 22+ handles TypeScript natively via --experimental-strip-types, and TypeScript 5.8 introduced --erasableSyntaxOnly more cleanly, verbatimModuleSyntax is effectively required if you want your TypeScript to run without a transformation step.
Here's the thing: when this flag is off, TypeScript can rewrite your imports. An import { SomeInterface } that's type-only might get stripped, or it might get emitted, depending on whether the compiler thinks it's a value. That ambiguity is fine until you're on an edge runtime or a tool that doesn't do the same inference TypeScript does. Then you get subtle bundling issues — not crashes, usually, just slightly wrong output that's hard to trace back.
The fix is boring: turn the flag on, let the compiler tell you which imports need import type, run the VS Code quick-fix on each file. It took me about ninety minutes across our codebase. I haven't thought about import emission since.
If you're still on a CommonJS Node.js setup with no edge runtime in sight, you can defer this. If you're deploying to Cloudflare Workers, Deno, or native Node.js strip mode — do it now.
Two Features I Overhyped in Slack, and Two Small Wins That Earned Their Place
After migrating the using declarations and cleaning up the predicates, I made the mistake of posting "TypeScript 5.x is actually great" in our engineering channel and listing six more features I was excited to explore. Two of them did not pan out the way I expected.
Decorator metadata (5.2). I went in thinking we could annotate validation schemas directly on request classes, reflect on them at runtime, and eliminate some boilerplate in our API layer. You can do that. The problem is runtime support — you need either a polyfill or an environment that natively supports the TC39 Decorator Metadata proposal. For our Node.js services, fine. For the React front-end running in whatever browsers our users have, I didn't want to ship a polyfill for something Zod schemas solved in an afternoon. If you're building a framework where you control the runtime, worth evaluating. For application code, the cost-benefit didn't work out.
Const type parameters (5.0). I do use these, just much less often than I expected. When you declare function foo<const T>(), TypeScript infers literal types for T instead of widening. Useful for typed config builders and tuple utilities. I've reached for it maybe eight times in the past year. Good to know it exists; not a weekly-driver feature.
The smaller wins that actually earned their place: preserved narrowing after last assignment (5.4) caught a real bug where a closure was capturing a variable I thought was permanently narrowed but could have been reassigned before the callback fired. The compiler surfaced it before it shipped. And regex syntax checking (5.5) has caught two invalid patterns that would have been silent runtime failures — the kind of thing that used to be completely invisible to the type system.
Anyway — those four. using declarations and inferred predicates are the ones I'd push on any TypeScript team right now, regardless of what kind of code they're writing. verbatimModuleSyntax is a one-time cost you pay once and never think about again. NoInfer you'll understand the second you hit the problem it solves.
The rest of 5.x I'd skim when a release drops and learn on demand. The six features I was posting about excitedly in Slack? Genuinely cool. Not in my daily workflow.
These four are.
Top comments (0)