TypeScript 5.x Modern Features (2024-2026)
1. const Type Parameters and const Assertions (as const)
| Feature | Syntax | Description | Benefit |
|---|---|---|---|
| const Type Parameters TS 5.0 | <const T> |
Infer literal types instead of widening to base type | Preserve literal types in generics |
| const Assertion | as const |
Narrow type to literal values, make properties readonly | Immutable data structures |
| const Context | const T extends U |
Apply const-like inference to constrained types | Fine-grained literal inference |
| Readonly Arrays | readonly [...] |
Create readonly arrays and tuples | Immutable collections |
Example: const type parameters
// Without const type parameter
function makeArray<T>(items: T[]) {
return items;
}
const arr1 = makeArray(['a', 'b', 'c']);
// Type: string[] (widened)
// With const type parameter (TS 5.0+)
function makeConstArray<const T>(items: T[]) {
return items;
}
const arr2 = makeConstArray(['a', 'b', 'c']);
// Type: readonly ["a", "b", "c"] (literal types preserved)
// Practical use case
function createConfig<const T extends Record<string, any>>(config: T) {
return config;
}
const config = createConfig({
apiUrl: 'https://api.example.com',
timeout: 5000,
retries: 3
});
// Type preserves literal values:
// {
// readonly apiUrl: "https://api.example.com";
// readonly timeout: 5000;
// readonly retries: 3;
// }
// Without const, would be:
// {
// apiUrl: string;
// timeout: number;
// retries: number;
// }
Example: const assertions (as const)
// Basic const assertion
const obj1 = { x: 10, y: 20 };
// Type: { x: number; y: number; }
const obj2 = { x: 10, y: 20 } as const;
// Type: { readonly x: 10; readonly y: 20; }
// Array literal
const arr1 = [1, 2, 3];
// Type: number[]
const arr2 = [1, 2, 3] as const;
// Type: readonly [1, 2, 3]
// String literal
const str1 = 'hello';
// Type: string
const str2 = 'hello' as const;
// Type: "hello"
// Nested objects
const routes = {
home: '/',
about: '/about',
contact: '/contact',
nested: {
profile: '/profile',
settings: '/settings'
}
} as const;
// All properties readonly, all values literal types
type Route = typeof routes.home; // Type: "/"
type NestedRoute = typeof routes.nested.profile; // Type: "/profile"
Example: Practical const patterns
// Enum-like object
const STATUS = {
PENDING: 'pending',
APPROVED: 'approved',
REJECTED: 'rejected'
} as const;
type Status = typeof STATUS[keyof typeof STATUS];
// Type: "pending" | "approved" | "rejected"
function updateStatus(status: Status) {
// status must be one of the literal values
}
updateStatus(STATUS.PENDING); // ✓ OK
updateStatus('pending'); // ✓ OK
updateStatus('invalid'); // ✗ Error
// Configuration with const
const CONFIG = {
api: {
baseUrl: 'https://api.example.com',
timeout: 5000,
endpoints: {
users: '/users',
posts: '/posts'
}
},
features: {
darkMode: true,
notifications: false
}
} as const;
// Extract types
type ApiEndpoint = typeof CONFIG.api.endpoints[keyof typeof CONFIG.api.endpoints];
// Type: "/users" | "/posts"
// Tuple with const
const point = [10, 20] as const;
// Type: readonly [10, 20]
function distance(p1: readonly [number, number], p2: readonly [number, number]) {
return Math.sqrt((p2[0] - p1[0]) ** 2 + (p2[1] - p1[1]) ** 2);
}
distance(point, [30, 40] as const); // ✓ OK
2. satisfies Operator for Type Checking TS 4.9
| Feature | Syntax | Description | vs Annotation |
|---|---|---|---|
| satisfies | value satisfies Type |
Validate type without widening inference | Preserves literal types |
| Type Annotation | value: Type |
Widen type to annotation | Loses literal types |
| Use Case | Type validation + inference | Get both type safety and narrow types | Best of both worlds |
Example: satisfies vs type annotation
type Colors = 'red' | 'green' | 'blue';
// Problem with type annotation - loses literal types
const palette1: Record<Colors, string | number[]> = {
red: '#ff0000',
green: [0, 255, 0],
blue: '#0000ff'
};
palette1.red.toUpperCase(); // ✗ Error: string | number[] doesn't have toUpperCase
palette1.green.map(x => x * 2); // ✗ Error: string | number[] doesn't have map
// Solution with satisfies - preserves literal types
const palette2 = {
red: '#ff0000',
green: [0, 255, 0],
blue: '#0000ff'
} satisfies Record<Colors, string | number[]>;
palette2.red.toUpperCase(); // ✓ OK: red is string
palette2.green.map(x => x * 2); // ✓ OK: green is number[]
palette2.yellow = '#ffff00'; // ✗ Error: yellow not in Colors
// satisfies validates structure but keeps narrow types
type RGB = [number, number, number];
const color = [255, 0, 0] satisfies RGB;
// Type: [number, number, number] (tuple, not array)
color[0] = 100; // ✓ OK
color[3] = 50; // ✗ Error: tuple only has 3 elements
Example: Practical satisfies patterns
// Route configuration with validation
type Route = {
path: string;
component: string;
children?: Route[];
};
const routes = [
{
path: '/',
component: 'Home'
},
{
path: '/about',
component: 'About',
children: [
{ path: '/about/team', component: 'Team' }
]
}
] satisfies Route[];
// Type is preserved as literal array, not Route[]
const homePath = routes[0].path; // Type: string, not widened
routes[0].path = '/home'; // ✓ Can mutate
// API response schema validation
type ApiResponse<T> = {
status: 'success' | 'error';
data?: T;
error?: string;
};
const response = {
status: 'success',
data: { id: 1, name: 'Alice' }
} satisfies ApiResponse<{ id: number; name: string }>;
if (response.status === 'success') {
// response.data is { id: number; name: string }, not optional
console.log(response.data.name); // ✓ OK
}
// Configuration with mixed types
type Config = {
[key: string]: string | number | boolean;
};
const config = {
host: 'localhost',
port: 3000,
ssl: true,
debug: false
} satisfies Config;
// Preserves specific types
const port: number = config.port; // ✓ OK: port is number
const host: string = config.host; // ✓ OK: host is string
Example: Complex satisfies scenarios
// Event handlers with satisfies
type EventHandlers = {
[K in string]: (...args: any[]) => void;
};
const handlers = {
onClick: (e: MouseEvent) => console.log(e.clientX),
onSubmit: (data: FormData) => console.log(data),
onError: (error: Error) => console.error(error.message)
} satisfies EventHandlers;
// Type of handlers.onClick is (e: MouseEvent) => void, not (...args: any[]) => void
handlers.onClick(new MouseEvent('click')); // ✓ Type-safe
// Discriminated unions with satisfies
type Action =
| { type: 'INCREMENT'; by: number }
| { type: 'DECREMENT'; by: number }
| { type: 'RESET' };
const actions = [
{ type: 'INCREMENT', by: 1 },
{ type: 'DECREMENT', by: 5 },
{ type: 'RESET' }
] satisfies Action[];
// Type preserved: each action has its specific shape
actions[0].by; // ✓ OK: number
actions[2].by; // ✗ Error: RESET doesn't have 'by'
Note: Use satisfies when you want:
- Type validation without widening to the annotation type
- To preserve literal types while ensuring structural compatibility
- Autocomplete for object properties while maintaining narrow types
- Both compile-time checking and runtime-friendly types
3. using Declaration for Resource Management TS 5.2
| Feature | Syntax | Description | Disposal |
|---|---|---|---|
| using Declaration | using resource = init(); |
Auto-dispose resource when scope exits | Calls Symbol.dispose |
| await using | await using resource = init(); |
Async disposal when scope exits | Calls Symbol.asyncDispose |
| Disposable | Symbol.dispose |
Interface for sync resource cleanup | Automatic cleanup |
| AsyncDisposable | Symbol.asyncDispose |
Interface for async resource cleanup | Async cleanup |
Example: Basic using declaration
// Implement Disposable interface
class FileHandle implements Disposable {
constructor(private path: string) {
console.log(`Opening ${path}`);
}
read() {
return `Content of ${this.path}`;
}
[Symbol.dispose]() {
console.log(`Closing ${this.path}`);
// Cleanup logic here
}
}
// Traditional approach - manual cleanup
function readFileOld() {
const file = new FileHandle('data.txt');
try {
return file.read();
} finally {
file[Symbol.dispose]();
}
}
// With using - automatic cleanup
function readFileNew() {
using file = new FileHandle('data.txt');
return file.read();
// file[Symbol.dispose]() called automatically at scope exit
}
// Multiple resources
function processFiles() {
using file1 = new FileHandle('input.txt');
using file2 = new FileHandle('output.txt');
// Use both files
const content = file1.read();
// Both disposed in reverse order (LIFO)
// file2 disposed first, then file1
}
Example: Async disposal with await using
// Implement AsyncDisposable
class DatabaseConnection implements AsyncDisposable {
constructor(private connectionString: string) {
console.log('Connecting...');
}
async query(sql: string) {
// Execute query
return [];
}
async [Symbol.asyncDispose]() {
console.log('Disconnecting...');
// Async cleanup
await this.closeConnection();
}
private async closeConnection() {
// Wait for pending operations
await new Promise(resolve => setTimeout(resolve, 100));
}
}
// Async resource management
async function queryDatabase() {
await using db = new DatabaseConnection('mongodb://localhost');
const results = await db.query('SELECT * FROM users');
// db[Symbol.asyncDispose]() called automatically
return results;
}
// Multiple async resources
async function processData() {
await using db = new DatabaseConnection('db1');
await using cache = new CacheConnection('redis://localhost');
const data = await db.query('SELECT * FROM data');
await cache.set('key', data);
// Disposed in reverse order: cache, then db
}
Example: Practical resource management patterns
// Lock/Mutex pattern
class Lock implements Disposable {
private locked = false;
acquire() {
if (this.locked) throw new Error('Already locked');
this.locked = true;
}
[Symbol.dispose]() {
this.locked = false;
}
}
function criticalSection() {
using lock = new Lock();
lock.acquire();
// Critical code here
console.log('In critical section');
// Lock automatically released at scope exit
}
// Transaction pattern
class Transaction implements AsyncDisposable {
private committed = false;
async execute(sql: string) {
// Execute SQL
}
async commit() {
this.committed = true;
// Commit transaction
}
async [Symbol.asyncDispose]() {
if (!this.committed) {
console.log('Rolling back transaction');
// Rollback if not committed
}
}
}
async function transferMoney(from: string, to: string, amount: number) {
await using tx = new Transaction();
await tx.execute(`UPDATE accounts SET balance = balance - ${amount} WHERE id = '${from}'`);
await tx.execute(`UPDATE accounts SET balance = balance + ${amount} WHERE id = '${to}'`);
await tx.commit();
// Transaction disposed: commit() called, so no rollback
}
// Cleanup multiple resources
class ResourcePool implements Disposable {
private resources: Disposable[] = [];
add(resource: Disposable) {
this.resources.push(resource);
}
[Symbol.dispose]() {
// Dispose all resources in reverse order
for (let i = this.resources.length - 1; i >= 0; i--) {
this.resources[i][Symbol.dispose]();
}
}
}
Note: The
using declaration implements explicit resource
management (similar to C#'s using, Python's with, Java's try-with-resources). Resources are disposed in
reverse declaration order (LIFO).
4. Decorator Metadata and Reflect API TS 5.0
| Feature | Status | Description | Use Case |
|---|---|---|---|
| Decorator Metadata | Stage 3 Proposal | Attach metadata to decorated declarations | Reflection, DI frameworks |
| Symbol.metadata | New symbol | Access decorator metadata | Runtime reflection |
| context.metadata | Decorator context | Store metadata during decoration | Framework integration |
| Reflect Metadata API | Library (reflect-metadata) | Design-time type reflection | DI, validation |
Example: Modern decorators with metadata (TS 5.0+)
// Enable in tsconfig.json:
// "experimentalDecorators": false (use new decorators)
// Class decorator with metadata
function Entity(tableName: string) {
return function<T extends { new(...args: any[]): {} }>(
target: T,
context: ClassDecoratorContext
) {
// Store metadata
context.metadata[Symbol.for('tableName')] = tableName;
return class extends target {
static tableName = tableName;
};
};
}
// Method decorator with metadata
function Route(path: string, method: string) {
return function(
target: Function,
context: ClassMethodDecoratorContext
) {
// Store route metadata
if (!context.metadata.routes) {
context.metadata.routes = [];
}
(context.metadata.routes as any[]).push({
path,
method,
handler: context.name
});
};
}
@Entity('users')
class User {
@Route('/users', 'GET')
getUsers() {
return [];
}
@Route('/users/:id', 'GET')
getUser(id: string) {
return { id };
}
}
// Access metadata
const metadata = (User as any)[Symbol.metadata];
console.log(metadata[Symbol.for('tableName')]); // 'users'
console.log(metadata.routes); // Array of route configs
Example: Reflect Metadata API (legacy approach)
// npm install reflect-metadata
// Enable in tsconfig.json:
// "experimentalDecorators": true
// "emitDecoratorMetadata": true
import 'reflect-metadata';
// Define metadata keys
const REQUIRED_KEY = Symbol('required');
const VALIDATION_KEY = Symbol('validation');
// Property decorator using Reflect
function Required(target: any, propertyKey: string) {
Reflect.defineMetadata(REQUIRED_KEY, true, target, propertyKey);
}
function MinLength(length: number) {
return function(target: any, propertyKey: string) {
Reflect.defineMetadata(VALIDATION_KEY, { minLength: length }, target, propertyKey);
};
}
class CreateUserDto {
@Required
@MinLength(3)
username!: string;
@Required
email!: string;
age?: number;
}
// Validation using metadata
function validate(obj: any): string[] {
const errors: string[] = [];
for (const key of Object.keys(obj)) {
// Check required
const isRequired = Reflect.getMetadata(REQUIRED_KEY, obj, key);
if (isRequired && !obj[key]) {
errors.push(`${key} is required`);
}
// Check validation rules
const validation = Reflect.getMetadata(VALIDATION_KEY, obj, key);
if (validation?.minLength && obj[key]?.length < validation.minLength) {
errors.push(`${key} must be at least ${validation.minLength} characters`);
}
}
return errors;
}
const dto = new CreateUserDto();
dto.username = 'ab'; // Too short
const errors = validate(dto); // ['email is required', 'username must be at least 3 characters']
Example: Design-time type reflection
import 'reflect-metadata';
// Automatic type reflection with emitDecoratorMetadata
function Log(target: any, propertyKey: string) {
// Get design-time type information
const type = Reflect.getMetadata('design:type', target, propertyKey);
const paramTypes = Reflect.getMetadata('design:paramtypes', target, propertyKey);
const returnType = Reflect.getMetadata('design:returntype', target, propertyKey);
console.log(`${propertyKey} type:`, type?.name);
console.log(`${propertyKey} params:`, paramTypes?.map((t: any) => t.name));
console.log(`${propertyKey} return:`, returnType?.name);
}
class Service {
@Log
processUser(user: User, id: number): Promise<boolean> {
return Promise.resolve(true);
}
}
// Logs:
// processUser type: Function
// processUser params: ['User', 'Number']
// processUser return: Promise
// Dependency injection with type reflection
const INJECT_KEY = Symbol('inject');
function Injectable() {
return function<T extends { new(...args: any[]): {} }>(target: T) {
// Store parameter types for DI
const paramTypes = Reflect.getMetadata('design:paramtypes', target) || [];
Reflect.defineMetadata(INJECT_KEY, paramTypes, target);
return target;
};
}
@Injectable()
class UserService {
constructor(private db: DatabaseService, private logger: LoggerService) {}
}
// DI Container
class Container {
resolve<T>(target: new (...args: any[]) => T): T {
const params = Reflect.getMetadata(INJECT_KEY, target) || [];
const instances = params.map((param: any) => this.resolve(param));
return new target(...instances);
}
}
const container = new Container();
const userService = container.resolve(UserService);
5. Import Attributes and JSON Modules TS 5.3
| Feature | Syntax | Description | Use Case |
|---|---|---|---|
| Import Attributes | import x from 'mod' with { } |
Specify module type/attributes on import | JSON, CSS modules |
| JSON Modules | import json from './data.json' with { type: 'json' } |
Import JSON as module | Static JSON import |
| resolveJsonModule | tsconfig option | Enable JSON module resolution | Type-safe JSON imports |
| Import Assertions | assert { type: 'json' } |
Legacy syntax (pre-TS 5.3) | Deprecated |
Example: JSON module imports
// tsconfig.json
{
"compilerOptions": {
"module": "esnext",
"resolveJsonModule": true,
"esModuleInterop": true
}
}
// data.json
{
"name": "MyApp",
"version": "1.0.0",
"config": {
"apiUrl": "https://api.example.com",
"timeout": 5000
}
}
// Modern syntax (TS 5.3+) with import attributes
import data from './data.json' with { type: 'json' };
console.log(data.name); // ✓ Type-safe: string
console.log(data.config.timeout); // ✓ Type-safe: number
// Type is inferred from JSON structure
type DataType = typeof data;
// {
// name: string;
// version: string;
// config: { apiUrl: string; timeout: number; }
// }
// Legacy syntax (pre-TS 5.3) - still works
import legacyData from './data.json' assert { type: 'json' };
// Without attributes (requires resolveJsonModule)
import simpleData from './data.json';
// Dynamic import with attributes
const dynamicData = await import('./data.json', {
with: { type: 'json' }
});
Example: CSS and other module types
// CSS Modules with import attributes
import styles from './styles.css' with { type: 'css' };
// In browsers supporting CSS modules
document.adoptedStyleSheets = [styles];
// TypeScript declaration for CSS modules
declare module '*.css' {
const stylesheet: CSSStyleSheet;
export default stylesheet;
}
// Web Assembly modules
import wasmModule from './module.wasm' with { type: 'webassembly' };
// Custom module types (requires bundler support)
import config from './config.toml' with { type: 'toml' };
import data from './data.yaml' with { type: 'yaml' };
// Type declarations for custom formats
declare module '*.toml' {
const content: Record<string, any>;
export default content;
}
declare module '*.yaml' {
const content: Record<string, any>;
export default content;
}
Example: Typed JSON imports
// config.json with strict typing
{
"database": {
"host": "localhost",
"port": 5432,
"name": "mydb"
},
"features": {
"auth": true,
"logging": true
}
}
// Import with type assertion
import configData from './config.json' with { type: 'json' };
// Define expected structure
interface Config {
database: {
host: string;
port: number;
name: string;
};
features: {
auth: boolean;
logging: boolean;
};
}
// Validate at runtime (optional)
function isValidConfig(data: unknown): data is Config {
return (
typeof data === 'object' &&
data !== null &&
'database' in data &&
'features' in data
);
}
// Use config with type safety
const config: Config = configData;
console.log(config.database.port); // ✓ Type: number
// Array of data
import users from './users.json' with { type: 'json' };
interface User {
id: number;
name: string;
email: string;
}
const typedUsers: User[] = users;
typedUsers.forEach(user => {
console.log(user.name.toUpperCase()); // ✓ Type-safe
});
6. Node16/NodeNext Module Resolution
| Feature | Description | Requirement | Behavior |
|---|---|---|---|
| Node16/NodeNext | Modern Node.js ESM resolution | File extensions required | Matches Node.js behavior |
| package.json "type" | Specify module system | "module" or "commonjs" | Determines .js meaning |
| Conditional Exports | Export different entry points | "exports" field | import vs require |
| File Extensions | Must include in imports | .js for .ts files | Mirrors runtime behavior |
Example: Node16/NodeNext configuration
// package.json for ESM project
{
"name": "my-package",
"version": "1.0.0",
"type": "module", // All .js files are ESM
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./utils": {
"types": "./dist/utils.d.ts",
"import": "./dist/utils.js"
}
}
}
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16", // or "NodeNext"
"moduleResolution": "Node16", // or "NodeNext"
"outDir": "./dist",
"declaration": true
}
}
// src/utils.ts
export function formatDate(date: Date): string {
return date.toISOString();
}
// src/index.ts - MUST use .js extension
import { formatDate } from './utils.js'; // ✓ .js even though source is .ts
import { formatDate } from './utils'; // ✗ Error: must include extension
// For directories, use index
import * as utils from './utils/index.js'; // ✓ OK
// Relative imports need extensions
import type { User } from './types.js'; // ✓ OK
import type { User } from './types'; // ✗ Error
Example: Dual package (CJS + ESM)
// package.json for dual package
{
"name": "dual-package",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.cjs", // CJS entry point
"module": "./dist/index.js", // ESM entry point
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js", // ESM
"require": "./dist/index.cjs", // CJS
"default": "./dist/index.js"
}
},
"files": ["dist"]
}
// Build with both formats
// tsconfig.json for ESM build
{
"compilerOptions": {
"module": "Node16",
"outDir": "./dist"
}
}
// Build CJS separately or use bundler
// Output:
// dist/index.js (ESM)
// dist/index.cjs (CJS)
// dist/index.d.ts (types)
// Consumers can use either
// ESM consumer:
import pkg from 'dual-package';
// CJS consumer:
const pkg = require('dual-package');
Example: Subpath exports and patterns
// package.json with detailed exports
{
"name": "my-lib",
"type": "module",
"exports": {
// Main entry
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
// Specific subpaths
"./utils": "./dist/utils.js",
"./helpers": "./dist/helpers.js",
// Pattern exports (all files in utils/)
"./utils/*": {
"types": "./dist/utils/*.d.ts",
"import": "./dist/utils/*.js"
},
// Conditional exports
"./server": {
"node": "./dist/server.js",
"default": "./dist/server-browser.js"
},
// Block access to internal files
"./internal/*": null,
// Package.json access
"./package.json": "./package.json"
}
}
// Usage:
import lib from 'my-lib'; // Main entry
import { util } from 'my-lib/utils'; // Subpath
import { helper } from 'my-lib/utils/string.js'; // Pattern
import server from 'my-lib/server'; // Conditional
import internal from 'my-lib/internal/secret'; // ✗ Error: blocked
Note: Node16/NodeNext resolution is the modern standard for
Node.js projects. Key differences from classic Node resolution:
- File extensions are required in imports
- Use .js extension even when importing .ts files
- package.json "type" field determines module system
- Respects "exports" field for subpath exports
- No automatic index.js resolution without explicit path
Warning: Node16/NodeNext is stricter than classic resolution:
- Path mappings (tsconfig paths) are not supported
- Must use .js extension in imports, not .ts
- Directory imports require /index.js explicitly
- Mixing ESM and CJS requires careful package.json configuration
TypeScript 5+ Features Summary
- const type parameters - Preserve literal types in generic functions with <const T>
- satisfies operator - Type validation without widening, preserves literal types
- using declaration - Automatic resource disposal with Symbol.dispose
- Decorator metadata - Attach metadata to decorators for reflection and DI
- Import attributes - Specify module type with 'with { type }' syntax
- Node16/NodeNext - Modern ESM resolution matching Node.js behavior