Skip to main content
Advertisement

7.4 TypeScript 5.x New Features

TypeScript adds new features to strengthen the type system with every minor release. From TypeScript 5.0 through 5.x, the accuracy of type inference and runtime safety have been greatly improved. This chapter covers the new features you will most often encounter in real-world code, illustrated with practical examples.


const Type Parameters (TS 5.0)

Previously, generic functions would infer wide types rather than literal types. The const type parameter (<const T>) introduced in TypeScript 5.0 automatically applies as const to a type parameter.

// Before TS 5.0: literal type is lost
function createRoute<T extends string>(path: T) {
return { path };
}

const r1 = createRoute("/users");
type R1Path = typeof r1.path; // string (not a literal!)

// TS 5.0: preserve the literal with a const type parameter
function createRouteConst<const T extends string>(path: T) {
return { path };
}

const r2 = createRouteConst("/users");
type R2Path = typeof r2.path; // "/users" (literal!)

// Applies to arrays too
function makeArray<const T>(items: T[]) {
return items;
}

const arr1 = makeArray(["a", "b", "c"]);
type Arr1Type = typeof arr1; // ("a" | "b" | "c")[] — before TS 5.0
// const arr1 = makeArray(["a", "b", "c"]);
// In actual TS 5.0: not readonly ["a", "b", "c"] but string[], const T differs for arrays

// Correct const tuple inference
function makeTuple<const T extends readonly unknown[]>(items: T): T {
return items;
}

const tup = makeTuple(["hello", 42, true] as const);
type TupType = typeof tup; // readonly ["hello", 42, true]

// Practical example: type-safe event name list
function defineEvents<const T extends string>(events: T[]): T[] {
return events;
}

const EVENTS = defineEvents(["click", "hover", "focus"]);
type EventName = (typeof EVENTS)[number]; // "click" | "hover" | "focus"

// Also works with objects
function createConfig<const T extends object>(config: T): T & { readonly __brand: "config" } {
return { ...config, __brand: "config" } as any;
}

const config = createConfig({ port: 3000, host: "localhost", debug: true });
type ConfigPort = typeof config.port; // 3000 (literal)
type ConfigHost = typeof config.host; // "localhost" (literal)

Variadic Tuple Types (TS 4.0)

Variadic tuple types were introduced in TypeScript 4.0. They let you use the spread operator at the type level to dynamically concatenate or split tuples.

// Basic variadic tuple
type Concat<T extends unknown[], U extends unknown[]> = [...T, ...U];

type C1 = Concat<[1, 2], [3, 4]>; // [1, 2, 3, 4]
type C2 = Concat<string[], number[]>; // (string | number)[]

// Prepend / append elements to a tuple
type Prepend<T extends unknown[], Item> = [Item, ...T];
type Append<T extends unknown[], Item> = [...T, Item];

type P1 = Prepend<[string, number], boolean>; // [boolean, string, number]
type A1 = Append<[string, number], boolean>; // [string, number, boolean]

// Powerful types built with variadic tuples
// curry function type (without explicit overloads)
type Curry<
Params extends unknown[],
Return
> = Params extends [infer First, ...infer Rest]
? Rest extends []
? (arg: First) => Return
: (arg: First) => Curry<Rest, Return>
: Return;

type AddCurry = Curry<[number, number, number], number>;
// (arg: number) => (arg: number) => (arg: number) => number

// Function pipeline type
type Pipeline<
Fns extends readonly ((...args: any[]) => any)[]
> = Fns extends readonly [infer F, ...infer Rest extends ((...args: any[]) => any)[]]
? F extends (...args: any[]) => infer R
? Rest extends []
? F
: (
...args: Parameters<F>
) => ReturnType<Last<Rest>>
: never
: never;

type Last<T extends any[]> = T extends [...any[], infer L] ? L : never;

// Practical example: type-safe function pipe
function createPipe<
const Fns extends readonly [
(...args: any[]) => any,
...Array<(arg: any) => any>
]
>(...fns: Fns) {
return (input: Parameters<Fns[0]>[0]) =>
fns.reduce((acc, fn) => fn(acc), input);
}

const pipeline = createPipe(
(n: number) => n * 2,
(n: number) => n.toString(),
(s: string) => s.padStart(5, "0")
);

const result = pipeline(42); // "00084"
type PipeResult = typeof result; // string

// Middleware stack type (Express style)
type MiddlewareFn<Ctx> = (ctx: Ctx, next: () => Promise<void>) => Promise<void>;

type MiddlewareStack<
Middlewares extends MiddlewareFn<any>[]
> = Middlewares extends [MiddlewareFn<infer Ctx>, ...any[]]
? Ctx
: never;

NoInfer — Preventing Type Inference (TS 5.4)

NoInfer<T> introduced in TypeScript 5.4 prevents type inference from occurring at a specific position. It controls where a type parameter is inferred to avoid unintended type widening.

// Problem: type inference also happens from defaultValue
function createValue<T>(value: T, defaultValue: T): T {
return value ?? defaultValue;
}

// "hello" and 0 together cause T to be inferred as string | number
const v1 = createValue("hello", 0);
// T = string | number (undesirable!)

// Solved with NoInfer: T is not inferred from defaultValue
function createValueSafe<T>(value: T, defaultValue: NoInfer<T>): T {
return value ?? defaultValue;
}

// T is inferred only from value → T = string → 0 is not a string, so it's an error
// const v2 = createValueSafe("hello", 0); // Error!
const v3 = createValueSafe("hello", "world"); // OK, T = string

// Practical example 1: registering event listeners
function addEventListener<const Events extends string>(
target: EventTarget,
events: Events[],
handler: (event: NoInfer<Events>) => void
): void {
events.forEach(e => target.addEventListener(e, handler as any));
}

// Inferred as "click" | "focus" from events, handler's event must be the same type
addEventListener(document, ["click", "focus"], (event) => {
// event: "click" | "focus"
console.log(event);
});

// Practical example 2: state machine transitions
function createStateMachine<const State extends string>(
initial: State,
transitions: Record<State, NoInfer<State>[]>
): { state: State; transition: (to: NoInfer<State>) => void } {
let state = initial;
return {
get state() { return state; },
transition(to: State) {
if (transitions[state].includes(to)) {
state = to;
}
},
};
}

const machine = createStateMachine("idle", {
idle: ["loading"],
loading: ["success", "error"],
success: ["idle"],
error: ["idle"],
});
// State is inferred as "idle" | "loading" | "success" | "error"
// Values in transitions are enforced to the same type

// Practical example 3: type-safe CSS variables
function getCssVar<const Name extends string>(
name: Name,
fallback: NoInfer<string>
): string {
return getComputedStyle(document.documentElement)
.getPropertyValue(`--${name}`) || fallback;
}

const color = getCssVar("primary-color", "#007bff");
// name infers as "primary-color", fallback is independently string

The using Keyword (TS 5.2) — Explicit Resource Management

TypeScript 5.2 supports the ECMAScript explicit resource management proposal (using, await using). Objects implementing Symbol.dispose / Symbol.asyncDispose are automatically released when they go out of scope.

// Basic pattern implementing Symbol.dispose
class DatabaseConnection {
private connected = false;

connect(dsn: string): void {
console.log(`Connecting to ${dsn}`);
this.connected = true;
}

query(sql: string): Promise<any[]> {
if (!this.connected) throw new Error("Not connected");
return Promise.resolve([]);
}

[Symbol.dispose](): void {
if (this.connected) {
console.log("Closing database connection");
this.connected = false;
}
}
}

// Automatic cleanup with using
function processOrders(): void {
using conn = new DatabaseConnection();
conn.connect("postgresql://localhost:5432/orders");

// Work with conn...
// When this block exits, conn[Symbol.dispose]() is called automatically
}
// conn.close() is called automatically on function exit — no try/finally needed!

// Async resource: Symbol.asyncDispose
class AsyncFileStream {
private stream: { write: (data: string) => Promise<void>; close: () => Promise<void> } | null = null;

async open(path: string): Promise<void> {
console.log(`Opening file: ${path}`);
this.stream = {
write: async (data) => { console.log(`Writing: ${data}`); },
close: async () => { console.log("File closed"); },
};
}

async write(data: string): Promise<void> {
await this.stream?.write(data);
}

async [Symbol.asyncDispose](): Promise<void> {
await this.stream?.close();
this.stream = null;
}
}

async function writeReport(): Promise<void> {
await using fileStream = new AsyncFileStream();
await fileStream.open("/reports/summary.txt");
await fileStream.write("Report content here");
// await fileStream[Symbol.asyncDispose]() is called automatically when the block exits
}

// DisposableStack: managing multiple resources together
function processWithMultipleResources(): void {
using stack = new DisposableStack();

const conn1 = stack.use(new DatabaseConnection());
conn1.connect("db1");

const conn2 = stack.use(new DatabaseConnection());
conn2.connect("db2");

// When stack is disposed, conn2 then conn1 are released in reverse order
}

// Practical example: automatic Lock release
class Mutex {
private locked = false;
private queue: (() => void)[] = [];

async acquire(): Promise<Disposable> {
while (this.locked) {
await new Promise<void>(resolve => this.queue.push(resolve));
}
this.locked = true;
return {
[Symbol.dispose]: () => {
this.locked = false;
const next = this.queue.shift();
next?.();
},
};
}
}

const mutex = new Mutex();

async function criticalSection(): Promise<void> {
using _lock = await mutex.acquire();
// Work while holding the lock
console.log("In critical section");
// Lock is released automatically when the block exits
}

The override Keyword

The override keyword introduced in TypeScript 4.3 explicitly marks when a child class redefines a method from a parent class. It is used together with the noImplicitOverride compiler option.

// tsconfig.json: { "noImplicitOverride": true }

class Animal {
name: string;

constructor(name: string) {
this.name = name;
}

speak(): string {
return `${this.name} makes a sound.`;
}

move(distance: number): string {
return `${this.name} moved ${distance}m.`;
}
}

class Dog extends Animal {
// override keyword required (noImplicitOverride: true)
override speak(): string {
return `${this.name} barks.`;
}

// Methods not in the parent do not use override
fetch(): string {
return `${this.name} fetches the ball!`;
}
}

// Safety net when a parent method is removed
class Cat extends Animal {
override speak(): string {
return `${this.name} meows.`;
}

// override move(distance: number): string {
// OK because move exists in parent; compile error if it doesn't
// }
}

// override with abstract classes
abstract class Shape {
abstract area(): number;
abstract perimeter(): number;

describe(): string {
return `Area: ${this.area()}, Perimeter: ${this.perimeter()}`;
}
}

class Circle extends Shape {
constructor(private radius: number) {
super();
}

override area(): number {
return Math.PI * this.radius ** 2;
}

override perimeter(): number {
return 2 * Math.PI * this.radius;
}
// describe() is not overridden — uses the parent's implementation
}

Access Modifiers in Constructor Parameters

Specifying an access modifier on a constructor parameter automatically declares and assigns the corresponding property.

// Old style (verbose)
class OldUser {
public readonly id: string;
public name: string;
protected email: string;
private passwordHash: string;

constructor(
id: string,
name: string,
email: string,
passwordHash: string
) {
this.id = id;
this.name = name;
this.email = email;
this.passwordHash = passwordHash;
}
}

// Concise style (access modifiers in constructor)
class User {
constructor(
public readonly id: string,
public name: string,
protected email: string,
private passwordHash: string
) {}
}

// With inheritance (TS 5.x improvements)
class AdminUser extends User {
constructor(
id: string,
name: string,
email: string,
passwordHash: string,
public readonly permissions: string[]
) {
super(id, name, email, passwordHash);
}
}

// Combined with generic classes
class Repository<T extends { id: string }> {
private items: Map<string, T> = new Map();

constructor(
private readonly tableName: string,
private readonly validator: (item: unknown) => item is T
) {}

save(item: T): void {
if (this.validator(item)) {
this.items.set(item.id, item);
}
}

findById(id: string): T | undefined {
return this.items.get(id);
}
}

Type Stripping (Node.js 22.6+, TS 5.x)

Native TypeScript support in TypeScript 5.x and Node.js 22.6+ allows running .ts files directly without a build step. Only the type syntax is stripped; the rest is executed as-is.

// server.ts — can be run directly with: node --experimental-strip-types server.ts

// Type annotations are removed at runtime
const PORT: number = 3000;
const HOST: string = "localhost";

interface ServerOptions {
port: number;
host: string;
debug?: boolean;
}

function createServer(options: ServerOptions): void {
const { port, host, debug = false } = options;
console.log(`Server running at http://${host}:${port}`);
if (debug) console.log("Debug mode enabled");
}

createServer({ port: PORT, host: HOST, debug: true });

// Note: Type Stripping only removes types
// Features that require runtime transformation are not supported:
// - enum (requires runtime object creation)
// - namespace (requires runtime object creation)
// - decorators (some metadata-related features)

// Supported:
// - type annotations
// - interface, type alias
// - as/satisfies expressions (only the type part is removed)
// - generic syntax
// - readonly, optional, and other type modifiers

// tsconfig for type stripping
// {
// "compilerOptions": {
// "verbatimModuleSyntax": true, // requires explicit import type
// "erasableSyntaxOnly": true // forbids syntax requiring runtime transformation
// }
// }

Practical Example 1: Precise Type Inference with const Parameters

// API endpoint builder
interface Endpoint<
Method extends string,
Path extends string,
Params extends Record<string, unknown> = {}
> {
method: Method;
path: Path;
params: Params;
call: (params: Params) => Promise<unknown>;
}

function defineEndpoint<
const Method extends "GET" | "POST" | "PUT" | "DELETE",
const Path extends string,
Params extends Record<string, unknown> = {}
>(
method: Method,
path: Path,
handler: (params: Params) => Promise<unknown>
): Endpoint<Method, Path, Params> {
return {
method,
path,
params: {} as Params,
call: handler,
};
}

const getUserEndpoint = defineEndpoint(
"GET",
"/api/users/:id",
async (params: { id: string }) => {
return { id: params.id, name: "Alice" };
}
);

type GetUserMethod = typeof getUserEndpoint.method; // "GET" (literal)
type GetUserPath = typeof getUserEndpoint.path; // "/api/users/:id" (literal)

// Building a router
const ENDPOINTS = [
defineEndpoint("GET", "/api/users", async () => []),
defineEndpoint("POST", "/api/users", async (p: { name: string; email: string }) => p),
defineEndpoint("GET", "/api/users/:id", async (p: { id: string }) => p),
defineEndpoint("DELETE", "/api/users/:id", async (p: { id: string }) => ({ deleted: p.id })),
] as const;

type EndpointMethod = (typeof ENDPOINTS)[number]["method"];
// "GET" | "POST" | "DELETE"

type EndpointPath = (typeof ENDPOINTS)[number]["path"];
// "/api/users" | "/api/users/:id"

Practical Example 2: Automatic DB Connection Cleanup with using

// Production-grade DB connection management
interface QueryResult<T> {
rows: T[];
rowCount: number;
duration: number;
}

class PostgresConnection {
private client: any = null;
private transactionDepth = 0;

constructor(private readonly connectionString: string) {}

async connect(): Promise<void> {
console.log(`Connecting to: ${this.connectionString}`);
// In production, this would be a pg.Client connection
this.client = { query: async (sql: string, params?: any[]) => ({ rows: [], rowCount: 0 }) };
}

async query<T>(sql: string, params?: any[]): Promise<QueryResult<T>> {
if (!this.client) throw new Error("Not connected");
const start = Date.now();
const result = await this.client.query(sql, params);
return { ...result, duration: Date.now() - start };
}

async beginTransaction(): Promise<void> {
this.transactionDepth++;
await this.query("BEGIN");
}

async commit(): Promise<void> {
await this.query("COMMIT");
this.transactionDepth--;
}

async rollback(): Promise<void> {
await this.query("ROLLBACK");
this.transactionDepth--;
}

async [Symbol.asyncDispose](): Promise<void> {
if (this.transactionDepth > 0) {
await this.rollback();
console.warn("Auto-rolled back uncommitted transaction");
}
if (this.client) {
console.log("Closing PostgreSQL connection");
this.client = null;
}
}
}

// Connection pool manager
class ConnectionPool {
private pool: PostgresConnection[] = [];
private readonly maxSize: number;

constructor(
private readonly connectionString: string,
maxSize = 10
) {
this.maxSize = maxSize;
}

async acquire(): Promise<PostgresConnection & AsyncDisposable> {
const conn = new PostgresConnection(this.connectionString);
await conn.connect();
this.pool.push(conn);
return conn;
}
}

// Real usage example
const pool = new ConnectionPool("postgresql://localhost:5432/myapp");

async function transferFunds(
fromId: string,
toId: string,
amount: number
): Promise<void> {
await using conn = await pool.acquire();

try {
await conn.beginTransaction();

await conn.query(
"UPDATE accounts SET balance = balance - $1 WHERE id = $2",
[amount, fromId]
);
await conn.query(
"UPDATE accounts SET balance = balance + $1 WHERE id = $2",
[amount, toId]
);

await conn.commit();
} catch (error) {
await conn.rollback();
throw error;
}
// conn[Symbol.asyncDispose] is called automatically when the function exits
// Any uncommitted transaction is automatically rolled back before the connection is closed
}

Pro Tips: TypeScript Feature Timeline by Version

VersionKey FeatureCore Keyword
TS 4.0Variadic Tuple Types[...T, ...U]
TS 4.1Template Literal Types`${T}`
TS 4.2Abstract Construct Signaturesabstract new()
TS 4.3override keywordoverride
TS 4.4Control Flow improvementsconst condition narrowing
TS 4.5Awaited, tail recursionAwaited<T>
TS 4.7infer extendsinfer R extends string
TS 4.8Intersection type simplification{} intersection improvements
TS 4.9satisfies operatorsatisfies
TS 5.0const type parameters, enum improvements<const T>
TS 5.1Independent setter/getter typesgetter/setter type mismatch allowed
TS 5.2using, Disposableusing, Symbol.dispose
TS 5.3import attributes improvementswith { type: "json" }
TS 5.4NoInfer, preserved narrowingNoInfer<T>
TS 5.5Inferred Type Predicatesautomatic type guard inference
TS 5.6Iterator Helper typesIterator<T>
TS 5.7Path rewriting, checks--rewriteRelativeImportExtensions
// TS 5.5 inferred type predicates (automatic type guard inference) example
const values = [1, null, "hello", undefined, 2, "world"] as const;

// Before TS 5.5: explicit type guard required
function isString(v: unknown): v is string {
return typeof v === "string";
}

// TS 5.5 and later: filter result type is inferred automatically
const strings = values.filter(v => typeof v === "string");
// Type: ("hello" | "world")[] ← null, undefined, number removed automatically

const numbers = values.filter(v => typeof v === "number");
// Type: (1 | 2)[]

// Complex conditions are also inferred automatically
interface ApiResponse {
status: "success" | "error";
data?: unknown;
error?: string;
}

const responses: ApiResponse[] = [
{ status: "success", data: { id: 1 } },
{ status: "error", error: "Not found" },
{ status: "success", data: { id: 2 } },
];

const successResponses = responses.filter(r => r.status === "success" && r.data != null);
// Type: { status: "success" | "error"; data?: unknown; error?: string; }[]
// (Not fully narrowed, but the condition is reflected)

Summary

FeatureVersionCore BenefitUse Scenario
<const T>TS 5.0Automatic literal type preservationFactory functions, config builders
Variadic TuplesTS 4.0Dynamic tuple manipulationCurrying, pipelines
NoInfer<T>TS 5.4Control over inference positionDefault values, event listeners
usingTS 5.2Automatic resource cleanupDB connections, files, locks
overrideTS 4.3Override safetyClass inheritance
Constructor access modifiersEarly TSEliminates boilerplateDomain model classes
Type StrippingNode 22.6+Run TS without a build stepDev scripts, tools

In the next chapter, you will learn how to implement custom utility types such as DeepPartial, DeepReadonly, and Brand types. We will also explore strategies for turning recurring type patterns in real-world code into reusable libraries.

Advertisement