Modern Web · Typescript

TypeScript 5 New Features — A Complete Guide

What TypeScript 5.x actually shipped — decorators, const type parameters, and the changes worth knowing about before you upgrade.

John Kihiu12 min read

TypeScript 5.0 was the first major-version bump the project had done since 5.0's predecessor was still called 4.x, and it landed with a genuinely large change: decorators. Since then the 5.x line has shipped in smaller, steadier increments — const type parameters, better enum handling, module resolution settings that actually match how bundlers work, and a runtime-adjacent feature for cleanup logic. None of it is flashy in the way a new framework release is. Most of it is TypeScript quietly closing gaps between what the type system could express and what people were actually writing.

Decorators finally stabilize (5.0)

Decorators had been usable in TypeScript for years behind the experimentalDecorators flag, implementing an old, pre-standardization proposal that never made it into JavaScript. TypeScript 5.0 implements the TC39 Stage 3 decorators proposal instead — the version that's actually on track to become part of the language. The two are not compatible: a class decorator written for experimentalDecorators will not run correctly under the new implementation, because the calling convention and what gets passed to the decorator function changed.

The practical shape of a Stage 3 decorator is a function that receives the target (a class, method, getter/setter, or field) and a context object describing it, then optionally returns a replacement:

TypeScript
function logged(target: Function, context: ClassMethodDecoratorContext) {
  const methodName = String(context.name);
  return function (this: unknown, ...args: unknown[]) {
    console.log(`calling ${methodName}`);
    return target.apply(this, args);
  };
}

class Invoicer {
  @logged
  generate(id: string) {
    return `invoice-${id}`;
  }
}

Because this is now a standards-track feature rather than a TypeScript-only extension, you no longer need experimentalDecorators or emitDecoratorMetadata to use it, and the emitted output doesn't rely on reflect-metadata. The catch is that libraries built around the old decorator model — older versions of Angular's compiler, some Nest.js internals, TypeORM's entity decorators — needed their own migration work to move to the new model, so "decorators work now" didn't mean every decorator-heavy library upgraded on day one.

Don't flip the flag on an existing decorator-heavy codebase blindly

If you're on Angular, NestJS, or TypeORM, check which decorator implementation your framework version targets before upgrading TypeScript. Mixing experimentalDecorators semantics with code written against the new proposal is a common source of "decorators just silently do nothing" bugs.

const type parameters (5.0)

Before 5.0, if you wanted a generic function to infer the most specific (literal) type from its argument, callers had to remember to add as const themselves. Forget it, and TypeScript would widen "north" to string during inference, which defeats the point of a lot of tuple- and literal-based APIs. Marking a type parameter as const pushes that inference behavior into the function signature itself, so the caller doesn't need to think about it:

TypeScript
function tuple(...items: T): T {
  return items;
}

// Without const T, this would infer string[]
const directions = tuple("north", "south", "east");
// directions: readonly ["north", "south", "east"]

This mattered most for libraries that model routing tables, state machine definitions, or SQL-adjacent query builders as tuples and objects of literals — anywhere the whole point of the API is "narrow types by default, and let the caller widen explicitly if they want to."

Enums got quietly more correct (5.0)

TypeScript 5.0 tightened up a long-standing inconsistency: unions of enum values weren't always treated as "all-literal" unions the way string or number literal unions were, which meant some type-narrowing and exhaustiveness checks that worked fine for plain literal unions didn't work the same way for enums. 5.0 makes enums with all-literal members behave consistently with other literal unions for these purposes — switch-based exhaustiveness checks and discriminated unions built on enum members narrow the way you'd expect, without special-casing enums as a second-class citizen of the type system.

This is a "things that used to be subtly wrong now aren't" fix rather than a new feature you write code against, but if you've ever hit an enum-based discriminated union that TypeScript refused to narrow the way an equivalent string-literal union did, this was that bug.

verbatimModuleSyntax replaces two confusing flags (5.0)

Before 5.0, controlling whether type-only imports got elided from the compiled output involved a tangle of flags — isolatedModules, preserveValueImports, and importsNotUsedAsValues — whose interactions were genuinely hard to reason about. verbatimModuleSyntax replaces all three with one rule that's easy to state: whatever you write for an import or export is exactly what shows up in the emitted JavaScript, except that anything explicitly marked type is dropped entirely.

TypeScript
import { Invoicer } from "./invoicer";       // kept as a value import
import type { Config } from "./config";      // fully elided at emit time
import { type Options, createServer } from "./server"; // Options elided, createServer kept

Turning this flag on tends to surface real bugs: imports that were only ever used as types but weren't marked type, which under the old flags might have silently survived into the output or silently been dropped depending on which combination of the three older flags you had set. It also matters for tools like esbuild or SWC that transpile files one at a time without full type information — they need the type keyword on the import itself to know it's safe to erase, since they can't check usage across the whole program the way tsc can.

Pairs naturally with isolatedModules

If you're already transpiling with esbuild, SWC, or Babel instead of tsc, turn on verbatimModuleSyntax alongside isolatedModules. It forces you to be explicit about type-only imports, which is exactly the information single-file transpilers need and can't infer on their own.

bundler module resolution and explicit resource management (5.0 / 5.2)

5.0 also added "moduleResolution": "bundler", a resolution mode that matches how Vite, esbuild, and webpack actually resolve imports — package exports maps, extensionless imports, no requirement to write .js at the end of a relative TypeScript import — instead of forcing you to pick between Node's older CommonJS-style resolution and the stricter Node16/NodeNext modes. If your build is going through a bundler rather than running through tsc or Node directly, bundler resolution stops TypeScript from rejecting import styles that your actual build tool handles fine.

Separately, TypeScript 5.2 implemented the using declaration from the TC39 explicit resource management proposal — a way to guarantee cleanup (closing a file handle, disposing a database connection, releasing a lock) runs when a value goes out of scope, without a manual try/finally:

TypeScript
function getConnection(): Disposable {
  const conn = openConnection();
  return {
    [Symbol.dispose]() {
      conn.close();
    },
  };
}

function run() {
  using conn = getConnection();
  conn.query("select 1");
} // conn.close() runs here, even if query() throws

This needs a target that supports Symbol.dispose (TypeScript polyfills the symbol itself if your lib target doesn't have it) and is most useful for the same category of thing try/finally was always clunky for: anything with an acquire/release pair where forgetting the release is a resource leak.

Wrapping up

None of the TypeScript 5.x releases were a rewrite of the language. What they were, consistently, was TypeScript catching up to how people actually write JavaScript now: real decorators instead of an abandoned proposal, module resolution that matches your bundler instead of fighting it, one flag instead of three for type-only imports, and enums that behave like the literal unions they've always effectively been. If you're upgrading from a pre-5.0 codebase, the decorator migration is the one part worth budgeting real time for — everything else is closer to "turn the setting on and fix what it flags."

John Kihiu
Acumatica ERP Developer · Laravel Engineer

Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.