Skip to content
TR

Phase 7 · Advanced and tooling · Lesson 7.3

Advanced

Modern TypeScript features

The features from TypeScript 4.9 to 5.8 that interviewers ask about: const type parameters, satisfies, standard decorators, accessor, using, NoInfer, inferred type predicates and erasableSyntaxOnly.

25 min

A lot of "senior TypeScript" questions are really "have you kept up?" questions. Every feature in this lesson exists because people kept writing the same workaround: as const at every call site, casts after .filter, try/finally blocks for cleanup, dummy type parameters to stop inference. For each one: the problem, the feature, an example.

const type parameters (TypeScript 5.0)

Problem. Generic functions infer widened types from literals. To keep the literal types, every caller had to remember as const.

function routes<T extends readonly string[]>(paths: T) {
  return paths;
}
 
const r = routes(["/home", "/about"]);
//    ^? const r: string[]

Feature. Put const before the type parameter. Inference now behaves as if the caller wrote as const:

function routes<const T extends readonly string[]>(paths: T) {
  return paths;
}
 
const r = routes(["/home", "/about"]);
//    ^? const r: readonly ["/home", "/about"]
 
type Route = (typeof r)[number];
//   ^? type Route = "/home" | "/about"

▶ Try it in the TypeScript Playground

Gotchas:

  • The constraint decides mutability. With T extends readonly string[] you get a readonly tuple; with T extends string[] TypeScript 5.9 infers a mutable tuple of literals (["/home", "/about"]). Nested object literals get readonly properties either way.
  • It only affects literals written at the call site. Passing a variable that was already widened (const list = ["/home"] is string[]) gives you string[].

satisfies (TypeScript 4.9), recap

Problem. An annotation checks a value but replaces its inferred type with the wider annotated one. No annotation keeps the precise type but checks nothing.

Feature. expr satisfies Type checks that the value conforms, and keeps the inferred type.

type Color = string | [number, number, number];
 
const palette = {
  red: [255, 0, 0],
  green: "#00ff00",
} satisfies Record<string, Color>;
 
palette.green.toUpperCase(); // ok: still known to be a string
palette.red.map((c) => c / 255); // ok: still known to be a tuple
 
const typo = {
  // @ts-expect-error -- Object literal may only specify known properties, and 'redd' does not exist in type 'Record<"red", Color>'.
  redd: [255, 0, 0],
} satisfies Record<"red", Color>;

Combine with as const ({...} as const satisfies Config) to get literal, readonly types that are still validated.

Standard decorators (TypeScript 5.0)

Problem. For years TypeScript only had experimental decorators, based on an old proposal that never shipped in JavaScript.

Feature. TypeScript 5.0 implements the standard (TC39 stage 3) decorators, and they're what you get by default. A decorator is a function that receives the decorated value and a context object, and may return a replacement.

function logged<This, Args extends unknown[], Return>(
  target: (this: This, ...args: Args) => Return,
  context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>,
) {
  const name = String(context.name);
  return function (this: This, ...args: Args): Return {
    console.log(`-> ${name}`, args);
    return target.call(this, ...args);
  };
}
 
class Greeter {
  constructor(private readonly greeting: string) {}
 
  @logged
  greet(name: string) {
    return `${this.greeting}, ${name}`;
  }
}
 
new Greeter("Hi").greet("Ada"); // logs "-> greet [ 'Ada' ]"

ClassMethodDecoratorContext is typed: it has kind: "method", name, static, private, access and addInitializer(fn). There are matching context types for classes, getters, setters, fields and accessors, and the compiler checks that a decorator is used on the right kind of member.

How this differs from experimentalDecorators

Standard (default since 5.0)experimentalDecorators: true
Signature(value, context)(target, propertyKey, descriptor)
Parameter decoratorsNot supportedSupported (used by DI frameworks)
emitDecoratorMetadataNot supportedSupported
Field decoratorsReceive undefined, may return an initializer functionReceive prototype + key
SpecJavaScript proposalLegacy TypeScript-only design

The two systems are not compatible: a decorator written for one doesn't work under the other. Existing frameworks that rely on parameter decorators or metadata still need experimentalDecorators.

Quick check

In a project with default compiler settings (TS 5.x, no experimentalDecorators), what happens with a decorator on a constructor parameter, like constructor(@inject() db: Db)?

The accessor keyword (TypeScript 4.9)

Problem. Decorating a field can't intercept reads and writes, because a field is just a slot.

Feature. accessor declares an auto-accessor: a getter/setter pair backed by a private storage slot. Accessor decorators receive { get, set } and can wrap both.

function clamp(min: number, max: number) {
  return function <This>(
    target: ClassAccessorDecoratorTarget<This, number>,
    context: ClassAccessorDecoratorContext<This, number>,
  ): ClassAccessorDecoratorResult<This, number> {
    return {
      set(value) {
        target.set.call(this, Math.min(max, Math.max(min, value)));
      },
    };
  };
}
 
class Volume {
  @clamp(0, 100) accessor level = 50;
}
 
const v = new Volume();
v.level = 250;
console.log(v.level); // 100

Without the decorator, accessor level = 50 behaves like a normal property to callers; the difference is that it's a getter/setter on the prototype, which decorators (and subclasses) can hook into.

using and await using (TypeScript 5.2)

Problem. Every resource (file handle, lock, DB connection, temporary subscription) needs a try/finally so cleanup runs even when code throws. Easy to forget, ugly to nest.

Feature. Explicit resource management. An object with a [Symbol.dispose]() method can be bound with using; when the block exits (normally or by throwing) its dispose method runs automatically. await using does the same with [Symbol.asyncDispose]().

class Timer implements Disposable {
  private readonly start = performance.now();
  constructor(private readonly label: string) {}
 
  [Symbol.dispose]() {
    console.log(`${this.label}: ${performance.now() - this.start}ms`);
  }
}
 
function work() {
  using t = new Timer("work");
  using u = new Timer("inner");
  // ... do things, maybe throw ...
} // disposes u, then t (reverse order), even on throw
 
async function withConnection() {
  await using conn = {
    async [Symbol.asyncDispose]() {
      console.log("connection closed");
    },
  };
  // use conn
}

▶ Try it in the TypeScript Playground

Details interviewers probe:

  • Disposal happens in reverse declaration order, like a stack.
  • using bindings are const-like and a value of null or undefined is allowed (nothing is disposed).
  • The types Disposable, AsyncDisposable, DisposableStack and AsyncDisposableStack come from the esnext.disposable lib (included in lib: ["esnext"]).
  • At runtime you need Symbol.dispose to exist: either a runtime that supports it or a polyfill, since TypeScript's downlevel emit relies on it.

NoInfer (TypeScript 5.4)

Problem. When a type parameter appears in several arguments, TypeScript infers from all of them. A default value can then widen the type instead of being checked against it.

function pick<T extends string>(options: T[], fallback: T) {
  return fallback;
}
 
pick(["small", "large"], "medium"); // no error: T is inferred as "small" | "large" | "medium"

Feature. Wrap a position in NoInfer<T> to say "don't infer T from here; just check against it".

function pick<T extends string>(options: T[], fallback: NoInfer<T>) {
  return fallback;
}
 
pick(["small", "large"], "large"); // ok
 
// @ts-expect-error -- Argument of type '"medium"' is not assignable to parameter of type '"small" | "large"'.
pick(["small", "large"], "medium");

Before 5.4 the workaround was a second type parameter (<T, D extends T>) or a helper like T & {}. NoInfer is an intrinsic type: it evaluates to T itself, only inference is blocked.

Quick check

What is the type of size?

function choose<T extends string>(all: T[], initial: T) {
  return initial;
}
const size = choose(["s", "m"], "xl");

Inferred type predicates (TypeScript 5.5)

Problem. .filter(x => x !== undefined) used to return (T | undefined)[], so you had to write (x): x is T => ... by hand.

Feature. TypeScript 5.5 infers a type predicate from a function body when it can prove the function returns true exactly when the parameter is the narrower type.

const ids = [1, undefined, 3].filter((x) => x !== undefined);
//    ^? const ids: number[]
 
function isString(value: unknown) {
  return typeof value === "string";
}
// inferred signature: function isString(value: unknown): value is string

The gotcha: the inference requires an "if and only if". A truthiness check doesn't qualify for numbers, because 0 is falsy: returning false would not prove the value is undefined.

const counts = [0, 1, undefined].filter((x) => !!x);
//    ^? const counts: (number | undefined)[]
 
// @ts-expect-error -- Type '(number | undefined)[]' is not assignable to type 'number[]'.
const total: number[] = counts;

It also only works for a function with a single return of a boolean expression and no explicit return type, and it needs a parameter that isn't reassigned.

--erasableSyntaxOnly (TypeScript 5.8)

Problem. Some runtimes, notably Node's built-in type stripping, run .ts files by deleting the type annotations and replacing them with whitespace. That only works for syntax that is purely type-level. A few TypeScript features generate JavaScript code, and those can't just be erased.

Feature. erasableSyntaxOnly makes those features compile errors, so your code is guaranteed to run under type stripping. It flags:

  • enum declarations
  • namespace / module declarations that contain runtime code
  • parameter properties (constructor(private x: number))
  • import x = require(...) and export =
// With "erasableSyntaxOnly": true, each of these is an error:
enum Direction { Up, Down }                      // generates an object
namespace Utils { export const pi = 3.14; }       // generates an IIFE
class Point { constructor(public x: number) {} }  // generates this.x = x

The erasable replacements: a const object with as const plus a union type instead of enum, plain ES modules instead of namespaces, and explicit field declarations instead of parameter properties. Type-only namespaces, declare enum and everything else that disappears entirely are still fine.

Spot the error

This cleanup helper looks right, but it does not compile. Why, and how do you fix it?

class Lock {
  release() {
    console.log("released");
  }
}
 
function critical() {
  // @ts-expect-error -- The initializer of a 'using' declaration must be either an object with a '[Symbol.dispose]()' method, or be 'null' or 'undefined'.
  using lock = new Lock();
  // ...
}
Show the answer

using doesn't know about a method called release. It calls [Symbol.dispose](), so the class must implement the Disposable protocol:

class Lock implements Disposable {
  release() {
    console.log("released");
  }
  [Symbol.dispose]() {
    this.release();
  }
}
 
function critical() {
  using lock = new Lock();
  // ... lock.release() runs automatically when the block exits
}

For third-party objects you can't change, use a DisposableStack and stack.defer(() => obj.release()).

Recap

  • const type parameters (5.0): literal inference at the call site without as const; a readonly constraint gives readonly tuples.
  • satisfies (4.9): validate against a type but keep the inferred type.
  • Standard decorators (5.0): (value, context) with typed contexts like ClassMethodDecoratorContext; incompatible with experimentalDecorators, no parameter decorators or metadata emit.
  • accessor (4.9): auto-accessor fields that decorators can intercept.
  • using / await using (5.2): automatic disposal via Symbol.dispose / Symbol.asyncDispose, in reverse order.
  • NoInfer<T> (5.4): check a position against T without inferring from it.
  • Inferred type predicates (5.5): only when the check is an exact "if and only if".
  • erasableSyntaxOnly (5.8): forbids syntax that emits code, for type-stripping runtimes.

Interview cards

1 / 7