What's Actually Useful in TypeScript 5.x
I keep up with ECMAScript here every few years, but TypeScript moves on a different cadence. Roughly three or four 5.x releases a year, each one a mix of compiler internals, polish, and the occasional language change that’s actually worth rewriting code over.
This post is the second category. After a couple years of 5.x releases, here are the features that have changed how I write TypeScript day to day. Skipping the rest.
5.0: const type parameters
The headline feature of 5.0, and still the one I reach for most.
function f<const T>(x: T): T;Before const type parameters, generic inference would widen literal types to their broader type unless the caller wrote as const themselves. That’s noise on every call site:
function pickFirst<T>(arr: readonly T[]): T {
return arr[0];
}
const result = pickFirst(["red", "green", "blue"]);
// ^? string
const result2 = pickFirst(["red", "green", "blue"] as const);
// ^? "red" | "green" | "blue"With const, the function declares that it wants the narrow form, and the caller stops needing to know:
function pickFirst<const T>(arr: readonly T[]): T {
return arr[0];
}
const result = pickFirst(["red", "green", "blue"]);
// ^? "red" | "green" | "blue"This is the right default for any generic API where the caller is passing a literal and you want to preserve it. Builder APIs, route definitions, schema constructors, anything that takes a tuple of strings and gives you a discriminated union back. If you maintain a library, look at every public generic function and ask whether const belongs on its type parameter. The answer is usually yes.
5.2: using declarations
This is the JavaScript Explicit Resource Management proposal, surfaced in TypeScript before it landed in V8.
using resource = openResource();
await using asyncResource = openAsyncResource();Any object with a [Symbol.dispose]() method (or [Symbol.asyncDispose]() for the async form) gets cleaned up automatically when its scope exits, in the reverse order it was declared.
function openFileHandle(path: string) {
const handle = fs.openSync(path, "r");
return {
handle,
[Symbol.dispose]() {
fs.closeSync(handle);
},
};
}
function readConfig() {
using file = openFileHandle("./config.json");
// ...read from file.handle...
} // file disposed here, even on throwPractical use cases that matter to me: database transactions that need a rollback path, file handles, network connections, mutex locks. The try/finally version of all of those is fine, but using is the version that’s hard to forget. You can’t accidentally skip the cleanup, because the cleanup is the declaration.
It does require runtime support for Symbol.dispose (Node 20+, modern bundlers polyfill it), so check your target before turning it on.
5.3: switch (true) narrowing
Small change, large quality-of-life impact.
function describe(value: unknown): string {
if (typeof value === "string") return `string: ${value}`;
if (typeof value === "number") return `number: ${value.toFixed(2)}`;
if (Array.isArray(value)) return `array of ${value.length}`;
return "unknown";
}function describe(value: unknown): string {
switch (true) {
case typeof value === "string":
return `string: ${value}`;
case typeof value === "number":
return `number: ${value.toFixed(2)}`;
case Array.isArray(value):
return `array of ${value.length}`;
default:
return "unknown";
}
}Before 5.3, the body of each case would not see the narrowed type. The compiler now propagates narrowing through the switch the same way it does through chained if/else statements. Whether you use switch (true) over if/else is taste, but the option is now real.
5.4: NoInfer<T>
A new utility type that fixes a long-standing inference quirk.
function f<T>(value: T, fallback: NoInfer<T>): T;The classic problem: when a function takes the same generic in two positions, TypeScript infers T from both, and the result is a union you didn’t ask for.
function withDefault<T>(value: T | undefined, fallback: T): T {
return value ?? fallback;
}
const result = withDefault("hello", 42);
// ^? string | number
// inferred from both args, which is almost never what you wantfunction withDefault<T>(value: T | undefined, fallback: NoInfer<T>): T {
return value ?? fallback;
}
const result = withDefault("hello", 42);
// ~~ Type 'number' is not assignable to type 'string'.NoInfer says “don’t use this position to infer T, only check against it.” The shape of the API stays the same; the error moves from runtime to compile time.
Reach for this anytime you have a generic parameter in multiple positions and one of them should be the source of truth. Fallback values, default options, type-guarded callbacks, all good fits.
5.4: Preserved narrowing in closures
This one isn’t a new syntax, it’s a behavior change that fixes a paper cut you’ve probably hit.
function process(value: string | null) {
if (value === null) return;
// value is string here
doLater(() => {
console.log(value.toUpperCase());
// ~~~~~ value might be 'string | null' (pre-5.4)
});
}Before 5.4, the closure couldn’t trust the narrowing from outside, because in principle value might have been reassigned between the check and the closure running. In 5.4 the compiler tracks the last assignment to a variable and preserves the narrowing into closures when nothing has reassigned it.
The practical effect is fewer non-null assertions in callback-heavy code. Event handlers, setTimeout, promise chains, forEach callbacks. They all stop needing the value! escape hatch in cases where the narrowing was already valid.
5.5: Inferred type predicates
The biggest day-to-day win in any 5.x release.
const items: (string | null)[] = ["a", null, "b"];
const filtered = items.filter((x) => x !== null);
// ^? (string | null)[]
// even though everything that survives the filter is a stringFor years, the workaround was a custom type predicate:
const filtered = items.filter((x): x is string => x !== null);
// ^? string[]In 5.5, the compiler infers the predicate when the callback is a simple narrowing expression:
const items: (string | null)[] = ["a", null, "b"];
const filtered = items.filter((x) => x !== null);
// ^? string[]This is the feature most likely to delete code from your codebase. Any custom is-predicate that exists only to make filter return a narrower type is now redundant. The compiler picks up x !== null, x !== undefined, typeof x === "string", Array.isArray(x), and similar shapes automatically.
It does not infer through arbitrary user functions yet. If your predicate is isValidUser(x), you still need the explicit x is User annotation on isValidUser itself. But for inline predicates inside filter, find, and friends, this just works now.
5.5: isolatedDeclarations
A build-performance flag that’s worth turning on if you ship a library or maintain a large monorepo.
{
"compilerOptions": {
"isolatedDeclarations": true,
"declaration": true
}
}The flag forces every exported declaration to have an explicit type annotation, which removes the need for the type checker to infer types when emitting .d.ts files. The payoff is that tools other than tsc (esbuild, swc, oxc) can emit declaration files correctly without doing a full type check.
For a small project, it’s overhead. For a monorepo where building all the packages takes minutes, getting .d.ts emit off tsc and onto a faster tool can cut build times significantly. The migration cost is real (you have to annotate every public export) but it’s mostly mechanical, and the explicit types are a documentation win regardless.
If you don’t ship .d.ts files, skip this one.
5.6: Iterator helpers
JavaScript’s Iterator helpers proposal shipped in V8 around the same time TypeScript 5.6 added typing for it. You can now chain .map, .filter, .take, .drop, .flatMap, and .reduce on any iterator, lazily.
function* naturals() {
let n = 1;
while (true) yield n++;
}
const firstFiveSquares = naturals()
.map((n) => n * n)
.take(5)
.toArray();
// ^? number[]
// [1, 4, 9, 16, 25]The 5.6 win is that the chaining now type-checks correctly, with the right element type at each step. Before 5.6 you’d lose generic information halfway through the chain.
The use case that’s actually changed how I write code: any time I’m processing a generator (parsing a stream, walking a tree, reading lines from a file), I no longer collect to an array first. The lazy chain reads the same as the eager .map().filter() version, but it stops as soon as .take() is satisfied.
This requires a recent enough runtime (Node 22+, modern browsers) and lib: ["ESNext"] or equivalent.
5.6: --noCheck
A flag that skips type checking entirely while still emitting JavaScript.
tsc --noCheck{
"compilerOptions": {
"noCheck": true
}
}The use case is splitting type-checking from emit in your CI pipeline. Run tsc --noEmit once for the type check, run tsc --noCheck (or use a faster transpiler) for the build. The two can run in parallel.
This is one of those flags that doesn’t matter on a small project and saves you minutes per build on a large one. The right place for it is in a build script that’s separate from your editor’s type checker, not as a default in your main tsconfig.json.
5.7: Never-initialized variable checks
A small new check that catches a bug class I’ve actually shipped.
let result: string;
if (someCondition) {
result = "yes";
}
console.log(result);
// ~~~~~~ Variable 'result' is used before being assigned.Before 5.7, this would only error if the variable’s type didn’t include undefined, and only sometimes. In 5.7, any variable that could plausibly be read before any assignment is flagged. The fix is usually to add an else branch or initialize at declaration.
I’ve shipped this exact bug at least twice. The check pays for itself.
5.8: --erasableSyntaxOnly
The flag that matters most now that runtimes execute TypeScript directly.
{
"compilerOptions": {
"erasableSyntaxOnly": true
}
}Node 23.6+ (and Deno, and Bun) can run a .ts file by stripping the types out and executing what’s left. That only works if every TypeScript-specific construct is erasable: deleting it leaves valid JavaScript behind. A handful of old TypeScript features aren’t. enum, namespace with runtime code, and constructor parameter properties all emit real JavaScript, so a type-stripper either has to compile them or choke on them.
erasableSyntaxOnly makes the compiler reject those constructs up front:
enum Direction {
Up,
Down,
}
// ~~~~~~~~~ This syntax is not allowed when 'erasableSyntaxOnly' is enabled.
class Point {
constructor(
public x: number,
public y: number,
) {}
// ~~~~~~ parameter properties are not allowed
}The fix is a const object (or a union of string literals) instead of enum, and explicit field assignments instead of parameter properties. Both are patterns I’d already drifted toward, so for me this flag mostly enforces a style I wanted anyway. If you plan to run TypeScript without a build step, turn it on before you write code that paints you into a corner.
5.9: import defer
The 5.9 language feature worth knowing about, though it’s narrower than the rest of this list.
import * as feature from "./some-feature.js";This is the deferred module evaluation proposal. A deferred import doesn’t run the module’s top-level code until you actually touch one of its exports.
// some-feature.ts runs initializationWithSideEffects() at its top level
import * as feature from "./some-feature.js";
// nothing has run yet
console.log(feature.specialConstant);
// the module evaluates here, on first property accessIt only works with namespace imports (import defer * as, never named or default), only under --module esnext or preserve, and TypeScript doesn’t transform it, so you need a runtime or bundler that supports it. The use case is a heavy or platform-specific module you might not end up needing on a given run. I haven’t reached for it often, but when an expensive module sits behind a conditional, this is cleaner than a dynamic import().
What I’d turn on today
If you’re on a 5.x version and haven’t adjusted your config in a while, the highest-value flags to consider:
noUncheckedIndexedAccess(older than 5.x but still under-used). Makesarray[i]returnT | undefined, which is what it actually is.isolatedDeclarationsif you ship.d.tsfiles.noUncheckedSideEffectImports(5.6) for catching typoed bare imports.erasableSyntaxOnly(5.8) if you run TypeScript directly under Node, Deno, or Bun, or plan to.- Upgrading to at least 5.5 if you’re still on 5.4 or earlier, just for inferred type predicates.
The features I’d reach for in code, in rough order of how often they show up in mine: inferred type predicates, const type parameters, using declarations, NoInfer, iterator helpers. The rest are situational. All of them are worth knowing exist so you can recognize the use case when it shows up.
One thing worth flagging even though it isn’t a language feature: the 7.0 beta landed in April 2026, and it’s the native Go rewrite of the compiler (Project Corsa, the tsgo binary). Microsoft is claiming roughly 10x faster builds, and the early numbers hold up. The VS Code codebase that takes 78 seconds to check under today’s tsc drops to about 7.5 seconds under tsgo. None of the language features in this post change, it’s the same type system, just a far faster implementation. If your build or editor feels slow on a large project, that’s the thing to watch this year.
If you want the official changelogs in long form, the TypeScript release notes are well-maintained and link out to the proposals each feature came from. This post is the highlight reel; the release notes are the source.