TypeScript 5+ Modern Features

1. const Type Parameters and const Assertions

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