GUIDE

TypeScript: Type-Safe Development at Scale

A comprehensive guide to TypeScript's type system, advanced patterns, compiler configuration, monorepo architecture, and migration from JavaScript. Based on running all microservices in TypeScript with a NestJS monorepo managed by Nx.

TypeScriptGenericsMapped TypesConditional TypesTemplate Literal TypesNestJSNxStrict ModeDeclaration FilesType GuardsBranded TypestsconfigMigration

Table of Contents

  1. 1. The Type System: Generics, Mapped Types, Conditional Types
  2. 2. Strict Mode and Compiler Options
  3. 3. Integration with Node.js, React, Angular
  4. 4. Declaration Files and Type Definitions
  5. 5. Advanced Patterns
  6. 6. Monorepo Setup with Nx
  7. 7. tsconfig Best Practices
  8. 8. Migration from JavaScript
  9. 9. ECMAScript 2025 and TypeScript Roadmap

1. The Type System: Generics, Mapped Types, Conditional Types

Generics: Parametric Polymorphism

Generics allow you to write functions, classes, and interfaces that work with any type while preserving type relationships. The key insight is that generics are not just "templates" but constraints on relationships between inputs and outputs. A generic type parameter captures the specific type at the call site and propagates it through the entire computation.

// Generic repository pattern with constrained types
interface Entity { id: string; createdAt: Date; updatedAt: Date; }

interface Repository<T extends Entity> {
  findById(id: string): Promise<T | null>;
  findMany(filter: Partial<Omit<T, keyof Entity>>): Promise<T[]>;
  create(data: Omit<T, keyof Entity>): Promise<T>;
  update(id: string, data: Partial<Omit<T, keyof Entity>>): Promise<T>;
  delete(id: string): Promise<void>;
}

// Usage: the filter type is automatically narrowed
interface User extends Entity { email: string; name: string; role: Role; }
const userRepo: Repository<User> = new MySQLRepository('users');
// filter is typed as Partial<{ email: string; name: string; role: Role }>
const admins = await userRepo.findMany({ role: Role.ADMIN });

Mapped Types

Mapped types iterate over the keys of a type and transform each property. They are the foundation of utility types like Partial<T>, Required<T>, Readonly<T>, and Pick<T, K>. The syntax [K in keyof T] iterates over all keys, while as clauses enable key remapping and filtering.

// Deep readonly: recursively makes all properties immutable
type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object
    ? T[K] extends Function
      ? T[K]
      : DeepReadonly<T[K]>
    : T[K];
};

// Create a type where all methods return Promises (for RPC clients)
type Promisify<T> = {
  [K in keyof T]: T[K] extends (...args: infer A) => infer R
    ? (...args: A) => Promise<R>
    : T[K];
};

// Filter type to only include string properties
type StringKeys<T> = {
  [K in keyof T as T[K] extends string ? K : never]: T[K];
};

Conditional Types

Conditional types follow the form T extends U ? X : Y. When combined with infer, they extract types from complex structures. Distributive conditional types (where the checked type is a naked type parameter) automatically distribute over unions. This behavior is fundamental for building type-level logic.

// Extract return type of async functions
type AsyncReturnType<T extends (...args: any) => Promise<any>> =
  T extends (...args: any) => Promise<infer R> ? R : never;

// Extract event payload type from an event map
type EventPayload<M, E extends keyof M> =
  M[E] extends { payload: infer P } ? P : never;

// Recursive type: flatten nested arrays
type Flatten<T> = T extends Array<infer U> ? Flatten<U> : T;
// Flatten<number[][][]> = number

// Type-safe path accessor (e.g., "user.address.city")
type PathValue<T, P extends string> =
  P extends `${infer K}.${infer Rest}`
    ? K extends keyof T
      ? PathValue<T[K], Rest>
      : never
    : P extends keyof T
      ? T[P]
      : never;

Template Literal Types

Template literal types combine literal types with string interpolation syntax to produce new string literal types. They enable type-safe string manipulation at the type level, which is invaluable for API route definitions, event names, CSS properties, and any domain where strings follow predictable patterns. Combined with conditional types and recursive types, they can parse and validate string formats at compile time.

// Type-safe event emitter using template literal types
type EventName = 'user' | 'order' | 'payment';
type EventAction = 'created' | 'updated' | 'deleted';
type EventString = `${EventName}:${EventAction}`;
// "user:created" | "user:updated" | "user:deleted" | "order:created" | ...

// Type-safe CSS utility classes
type Size = 'sm' | 'md' | 'lg' | 'xl';
type Direction = 'top' | 'right' | 'bottom' | 'left';
type SpacingClass = `m${Direction extends infer D extends string ? Capitalize<D> : never}-${Size}`;

// HTTP route builder with type-safe path parameters
type ExtractParams<T extends string> =
  T extends `${string}:${infer Param}/${infer Rest}`
    ? Param | ExtractParams<Rest>
    : T extends `${string}:${infer Param}`
      ? Param
      : never;

type UserRouteParams = ExtractParams<'/users/:userId/posts/:postId'>;
// "userId" | "postId"

// Enforce dot-notation paths for nested objects
type DotPath<T, Prefix extends string = ''> = T extends object
  ? { [K in keyof T & string]:
      | `${Prefix}${K}`
      | DotPath<T[K], `${Prefix}${K}.`>
    }[keyof T & string]
  : never;

2. Strict Mode and Compiler Options

The "strict": true flag enables a family of strict checks that are essential for production code. Each sub-flag addresses a specific class of runtime errors that TypeScript can prevent at compile time. Running without strict mode defeats the purpose of using TypeScript and allows entire categories of bugs to pass through.

Critical Strict Flags

// tsconfig.json - production-grade strict configuration
{
  "compilerOptions": {
    // Strict family (all enabled by "strict": true)
    "strict": true,
    "noImplicitAny": true,           // No implicit 'any' types
    "strictNullChecks": true,         // null/undefined are distinct types
    "strictFunctionTypes": true,      // Contravariant function parameter checking
    "strictBindCallApply": true,      // Check bind/call/apply argument types
    "strictPropertyInitialization": true, // Class properties must be initialized
    "noImplicitThis": true,           // No implicit 'this' type
    "useUnknownInCatchVariables": true,   // catch(e) is 'unknown', not 'any'
    "alwaysStrict": true,             // Emit "use strict" in every file

    // Additional safety flags (NOT included in "strict")
    "noUncheckedIndexedAccess": true, // T[key] returns T | undefined
    "exactOptionalPropertyTypes": true, // Distinguish missing vs. undefined
    "noFallthroughCasesInSwitch": true,
    "noImplicitReturns": true,
    "noImplicitOverride": true,       // Require 'override' keyword
    "forceConsistentCasingInFileNames": true,
    "isolatedModules": true           // Required for esbuild/swc
  }
}

strictNullChecks in Practice

Without strictNullChecks, every type implicitly includes null and undefined. This means string actually means string | null | undefined, and TypeScript cannot catch null dereference errors. With strict null checks enabled, you must explicitly handle nullability using optional chaining (?.), nullish coalescing (??), or type guards.

// Without strictNullChecks: this compiles but crashes at runtime
function getLength(s: string): number {
  return s.length; // Runtime: Cannot read property 'length' of null
}
getLength(null); // No error without strictNullChecks

// With strictNullChecks: the compiler catches it
function getLength(s: string | null): number {
  if (s === null) return 0;  // Type narrowed to 'string' after this check
  return s.length;           // Safe: s is guaranteed to be string here
}
In production, enabling noUncheckedIndexedAccess across the monorepo revealed 200+ potential null dereference bugs in array/object access patterns. We resolved them over two weeks, and the runtime error rate in production dropped by 35% in the following month.

3. Integration with Node.js, React, Angular

Node.js + TypeScript

For Node.js microservices, TypeScript provides type safety across the entire request lifecycle: validated request bodies, typed database queries, typed configuration, and typed inter-service communication. The @types/node package provides types for all Node.js built-in modules. With NestJS, TypeScript is a first-class citizen: decorators, dependency injection, and module boundaries are all type-checked.

// Typed configuration with validation
import { z } from 'zod';

const ConfigSchema = z.object({
  PORT: z.coerce.number().default(3000),
  DATABASE_URL: z.string().url(),
  REDIS_URL: z.string().url(),
  JWT_SECRET: z.string().min(32),
  LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
  CORS_ORIGINS: z.string().transform(s => s.split(',')),
});

type Config = z.infer<typeof ConfigSchema>;

// Validated at startup - crashes immediately if config is invalid
export const config: Config = ConfigSchema.parse(process.env);

React + TypeScript

React's component model maps naturally to TypeScript interfaces. Props are typed as interfaces, hooks infer return types automatically, and context providers enforce type contracts. The React.FC type is now discouraged in favor of explicit prop typing, which gives better type inference and avoids implicit children props.

// Modern React component typing (no React.FC)
interface UserCardProps {
  user: User;
  onEdit: (id: string) => void;
  variant?: 'compact' | 'expanded';
}

function UserCard({ user, onEdit, variant = 'compact' }: UserCardProps) {
  const [isLoading, setIsLoading] = useState(false);
  // isLoading inferred as boolean, setIsLoading as Dispatch<SetStateAction<boolean>>

  const handleEdit = useCallback(() => {
    onEdit(user.id); // TypeScript ensures user.id is string
  }, [user.id, onEdit]);

  return (/* JSX */);
}

Angular + TypeScript

Angular was built with TypeScript from inception. Decorators (@Component, @Injectable, @NgModule) are TypeScript experimental decorators. Angular's template type checking (strictTemplates) validates bindings in HTML templates against component types, catching errors like typos in property names or incorrect event handler signatures at compile time.

4. Declaration Files and Type Definitions

Declaration files (.d.ts) describe the shape of JavaScript libraries without containing implementation. The @types ecosystem on npm provides community-maintained declarations for thousands of packages. When a library includes its own types ("types" field in package.json), no separate @types package is needed.

Writing Declaration Files

// types/zeromq-extended.d.ts - augmenting ZeroMQ types for our use case
import { Socket } from 'zeromq';

declare module 'zeromq' {
  interface Socket {
    sendJson(data: unknown): Promise<void>;
    receiveJson<T>(): Promise<T>;
  }
}

// types/global.d.ts - ambient declarations for environment
declare namespace NodeJS {
  interface ProcessEnv {
    NODE_ENV: 'development' | 'staging' | 'production';
    DATABASE_URL: string;
    REDIS_URL: string;
    JWT_SECRET: string;
  }
}

// types/express-extensions.d.ts - extending Express Request
declare namespace Express {
  interface Request {
    userId?: string;
    tenantId?: string;
    permissions?: string[];
  }
}

Declaration Strategy for Microservices

In a microservices monorepo, shared type definitions go in a @company/types package. This package contains domain entities, DTOs, event schemas, and API contracts. It is published as a TypeScript package (with .d.ts files) and consumed by all services. Changes to shared types trigger type checking across all dependent services in the CI pipeline, catching breaking changes before deployment.

5. Advanced Patterns

Branded Types (Nominal Typing)

TypeScript uses structural typing: two types are compatible if their shapes match. This means UserId and OrderId are interchangeable if both are string. Branded types add a phantom property to create nominal distinction, preventing accidental mixing of domain identifiers.

// Branded types: prevent mixing IDs from different domains
type Brand<T, B extends string> = T & { readonly __brand: B };

type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;
type TenantId = Brand<string, 'TenantId'>;

function createUserId(id: string): UserId { return id as UserId; }
function createOrderId(id: string): OrderId { return id as OrderId; }

function getUser(id: UserId): Promise<User> { /* ... */ }
function getOrder(id: OrderId): Promise<Order> { /* ... */ }

const userId = createUserId('usr_123');
const orderId = createOrderId('ord_456');

getUser(userId);    // OK
getUser(orderId);   // COMPILE ERROR: OrderId is not assignable to UserId
getOrder(orderId);  // OK

Discriminated Unions

Discriminated unions use a shared literal property (the discriminant) to enable exhaustive type narrowing in switch statements. This pattern is essential for modeling state machines, event systems, and API responses where different variants carry different data.

// Event system with discriminated unions
type DomainEvent =
  | { type: 'USER_CREATED'; payload: { userId: string; email: string } }
  | { type: 'USER_UPDATED'; payload: { userId: string; changes: Partial<User> } }
  | { type: 'ORDER_PLACED'; payload: { orderId: string; items: OrderItem[] } }
  | { type: 'PAYMENT_RECEIVED'; payload: { orderId: string; amount: number } };

// Exhaustive handler: TypeScript ensures all event types are handled
function handleEvent(event: DomainEvent): void {
  switch (event.type) {
    case 'USER_CREATED':
      // event.payload is narrowed to { userId: string; email: string }
      sendWelcomeEmail(event.payload.email);
      break;
    case 'USER_UPDATED':
      invalidateUserCache(event.payload.userId);
      break;
    case 'ORDER_PLACED':
      processOrder(event.payload.orderId, event.payload.items);
      break;
    case 'PAYMENT_RECEIVED':
      confirmPayment(event.payload.orderId, event.payload.amount);
      break;
    default:
      // exhaustiveCheck: if a new event type is added, this line errors
      const _exhaustive: never = event;
      throw new Error(`Unhandled event: ${(_exhaustive as any).type}`);
  }
}

Type-Safe Builder Pattern

TypeScript's type system can enforce that a builder produces a valid object only when all required properties have been set. This pattern uses mapped types to track which fields have been configured, making the build() method available only when all required fields are present.

// Type-safe query builder
class QueryBuilder<T, Selected extends keyof T = never> {
  private selectFields: string[] = [];
  private whereConditions: string[] = [];

  select<K extends keyof T>(...fields: K[]): QueryBuilder<T, Selected | K> {
    this.selectFields.push(...fields.map(String));
    return this as any;
  }

  where<K extends keyof T>(field: K, op: '=' | '>' | '<', value: T[K]): this {
    this.whereConditions.push(`${String(field)} ${op} ?`);
    return this;
  }

  // Returns only the selected fields
  async execute(): Promise<Pick<T, Selected>[]> {
    // ... execute SQL query
    return [] as any;
  }
}

// Usage: result is typed as { name: string; email: string }[]
const users = await new QueryBuilder<User>()
  .select('name', 'email')
  .where('role', '=', Role.ADMIN)
  .execute();

Type Guards and Narrowing

Type guards are runtime checks that narrow a type within a conditional block. TypeScript supports built-in narrowing with typeof, instanceof, and in, but custom type guard functions (using the is keyword) are essential for complex domain logic. Assertion functions (asserts x is T) narrow types for the rest of the scope rather than just within an if block.

// Custom type guard for API responses
interface SuccessResponse<T> { ok: true; data: T; }
interface ErrorResponse { ok: false; error: string; code: number; }
type ApiResponse<T> = SuccessResponse<T> | ErrorResponse;

function isSuccess<T>(res: ApiResponse<T>): res is SuccessResponse<T> {
  return res.ok === true;
}

async function fetchUser(id: string): Promise<User> {
  const res: ApiResponse<User> = await api.get(`/users/${id}`);
  if (isSuccess(res)) {
    return res.data; // narrowed to SuccessResponse<User>
  }
  throw new AppError(res.error, res.code); // narrowed to ErrorResponse
}

// Assertion function: narrows for the rest of the scope
function assertDefined<T>(val: T | undefined, msg: string): asserts val is T {
  if (val === undefined) throw new Error(msg);
}

function processOrder(order: Order | undefined) {
  assertDefined(order, 'Order not found');
  // After assertion, order is narrowed to Order for the rest of this function
  console.log(order.id, order.items.length);
}

// Discriminated union guard with "in" operator
interface Dog { bark(): void; breed: string; }
interface Cat { meow(): void; color: string; }
type Pet = Dog | Cat;

function isDog(pet: Pet): pet is Dog {
  return 'bark' in pet;
}

6. Monorepo Setup with Nx

Nx is a build system optimized for monorepos. It provides computation caching, task orchestration, dependency graph analysis, and code generators. For a NestJS microservices monorepo, Nx manages build order, ensures only affected services are rebuilt on changes, and caches build artifacts locally and remotely.

Monorepo Structure

app-monorepo/
  apps/
    user-service/          # NestJS microservice
    billing-service/       # NestJS microservice
    notification-service/  # NestJS microservice
    admin-api/             # NestJS API gateway
  libs/
    shared/
      types/               # @myorg/types - shared domain types
      utils/               # @myorg/utils - shared utilities
      testing/             # @myorg/testing - test helpers and factories
    database/              # @myorg/database - TypeORM entities and migrations
    messaging/             # @myorg/messaging - ZeroMQ/Redis abstractions
    auth/                  # @myorg/auth - JWT validation, guards
  tools/
    generators/            # Custom Nx generators for scaffolding
  nx.json
  tsconfig.base.json       # Shared TypeScript config

TypeScript Project References

Project references ("references" in tsconfig) enable incremental compilation across packages. When @myorg/types changes, only services that depend on it are recompiled. Combined with Nx's affected commands, this reduces CI build times from 15 minutes to 2-3 minutes for typical pull requests.

// tsconfig.base.json - shared compiler options
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@myorg/types": ["libs/shared/types/src/index.ts"],
      "@myorg/utils": ["libs/shared/utils/src/index.ts"],
      "@myorg/database": ["libs/database/src/index.ts"],
      "@myorg/messaging": ["libs/messaging/src/index.ts"],
      "@myorg/auth": ["libs/auth/src/index.ts"]
    },
    "strict": true,
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "declaration": true,
    "composite": true,
    "incremental": true
  }
}

// apps/user-service/tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": { "outDir": "../../dist/apps/user-service" },
  "references": [
    { "path": "../../libs/shared/types" },
    { "path": "../../libs/database" },
    { "path": "../../libs/messaging" },
    { "path": "../../libs/auth" }
  ]
}

Nx Task Pipeline

Nx's task pipeline ensures correct build order and enables parallelism. The nx affected:build command only builds services affected by the current changeset. Combined with distributed caching (Nx Cloud), a developer pushing changes to @myorg/types sees cached builds for unchanged services and only recompiles the affected ones.

// nx.json - task pipeline configuration
{
  "targetDefaults": {
    "build": {
      "dependsOn": ["^build"],   // build dependencies first
      "cache": true,
      "inputs": ["production", "^production"]
    },
    "test": {
      "dependsOn": ["build"],
      "cache": true,
      "inputs": ["default", "^production"]
    },
    "lint": {
      "cache": true,
      "inputs": ["default", "{workspaceRoot}/.eslintrc.json"]
    }
  },
  "namedInputs": {
    "production": ["default", "!{projectRoot}/**/*.spec.ts"],
    "default": ["{projectRoot}/**/*"]
  }
}
In production, migrating from 16 separate repositories to a single Nx monorepo reduced cross-service refactoring time from days to hours. A rename of a shared type like SubscriptionStatus propagated automatically across all 16 services, with TypeScript catching every usage at compile time. CI times dropped from 45 minutes (running all builds) to 4 minutes (only affected services).

7. tsconfig Best Practices

A well-configured tsconfig.json is the foundation of a maintainable TypeScript project. Beyond strict mode flags, the module system, target, and path resolution settings have significant impact on build performance, bundle size, and developer experience. Different project types (library, Node.js service, React app, monorepo) require different configurations.

Node.js Service Configuration

// tsconfig.json - production Node.js microservice
{
  "compilerOptions": {
    "target": "ES2022",               // Node 18+ supports ES2022 natively
    "module": "NodeNext",              // Native ESM with .js extensions
    "moduleResolution": "NodeNext",    // Follows Node.js resolution algorithm
    "strict": true,
    "esModuleInterop": true,           // Correct CJS/ESM interop
    "skipLibCheck": true,              // Skip checking node_modules .d.ts
    "declaration": true,               // Generate .d.ts for library consumers
    "declarationMap": true,            // Source maps for declarations (go-to-definition)
    "sourceMap": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "resolveJsonModule": true,         // Import JSON files with types
    "noUncheckedIndexedAccess": true,  // Array/object access returns T | undefined
    "exactOptionalPropertyTypes": true,
    "noImplicitOverride": true,
    "forceConsistentCasingInFileNames": true,
    "isolatedModules": true,           // Required for esbuild/swc transpilers
    "verbatimModuleSyntax": true       // Enforce explicit type-only imports
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist", "**/*.spec.ts"]
}

React Application Configuration

// tsconfig.json - React with Vite
{
  "compilerOptions": {
    "target": "ES2020",
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "moduleResolution": "bundler",     // Vite/webpack resolution semantics
    "jsx": "react-jsx",                // React 17+ automatic JSX transform
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "isolatedModules": true,
    "allowImportingTsExtensions": true,
    "noEmit": true,                    // Vite handles bundling
    "paths": {
      "@/*": ["./src/*"],
      "@components/*": ["./src/components/*"],
      "@hooks/*": ["./src/hooks/*"]
    }
  },
  "include": ["src"],
  "references": [{ "path": "./tsconfig.node.json" }]
}

Shared Library Configuration

// tsconfig.json - shared library published to npm
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "composite": true,                 // Required for project references
    "incremental": true,               // Cache compilation results
    "stripInternal": true              // Remove @internal JSDoc from .d.ts
  },
  "include": ["src/**/*"],
  "exclude": ["src/**/*.spec.ts"]
}
Key insight: verbatimModuleSyntax (TypeScript 5.0+) replaces importsNotUsedAsValues and preserveValueImports. It forces you to use import type { X } for type-only imports, making the intent explicit and enabling better tree-shaking. This is now the recommended approach for all new projects.

8. Migration from JavaScript

Migrating a JavaScript codebase to TypeScript is best done incrementally. The key principle is that every step should leave the codebase in a working state. TypeScript's allowJs option lets .ts and .js files coexist in the same project, so you can migrate file by file without stopping feature development.

Migration Phases

// Phase 1: Setup - enable TypeScript alongside JavaScript
// tsconfig.json (initial migration config)
{
  "compilerOptions": {
    "allowJs": true,                   // Allow .js files in the project
    "checkJs": false,                  // Don't type-check .js files yet
    "strict": false,                   // Enable gradually
    "target": "ES2020",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "./dist",
    "rootDir": "./src",
    "esModuleInterop": true,
    "resolveJsonModule": true
  },
  "include": ["src/**/*"]
}

// Phase 2: Rename .js to .ts one module at a time, fix errors
// Start with leaf modules (no imports from other project files)
// Add explicit types to function signatures and exports

// Phase 3: Enable strict flags incrementally
// 1. "noImplicitAny": true        (biggest impact, most work)
// 2. "strictNullChecks": true     (second biggest impact)
// 3. "strictFunctionTypes": true  (usually few changes needed)
// 4. "strict": true               (enable remaining flags at once)

Practical Strategies

The most effective migration strategy is to type the boundaries first: API handlers, database queries, and shared interfaces. This provides immediate safety at the points where most runtime errors occur. Internal helper functions can be migrated later with less urgency. Use // @ts-expect-error as a temporary escape hatch with a tracking comment, not any casts that hide problems permanently.

// Strategy 1: Type the boundaries first
// Before (JavaScript)
async function getUser(req, res) {
  const user = await db.query('SELECT * FROM users WHERE id = ?', [req.params.id]);
  res.json(user);
}

// After (TypeScript - typed boundaries)
import { Request, Response } from 'express';
import { User } from '@company/types';

async function getUser(req: Request<{ id: string }>, res: Response<User>) {
  const user = await db.query<User>('SELECT * FROM users WHERE id = ?', [req.params.id]);
  res.json(user);
}

// Strategy 2: Use @ts-expect-error with tracking, not blanket "any"
function legacyParser(data: unknown): ParsedResult {
  // @ts-expect-error TODO: MIGRATE-123 - legacy format handling
  return oldParser.parse(data);
}

// Strategy 3: Create a migration tracking script
// Count remaining .js files and untyped boundaries
// npx tsc --noEmit 2>&1 | grep "error TS" | wc -l  # Track error count over time

Migration Checklist

A structured checklist prevents migration from stalling. Track progress with a simple metric: percentage of .ts files vs. total source files, and the count of // @ts-expect-error and explicit any annotations. Both numbers should trend toward zero over time.

// Migration checklist for each service:
// [ ] Install typescript, @types/node, and framework @types packages
// [ ] Create tsconfig.json with allowJs: true, strict: false
// [ ] Configure build tool (esbuild/swc/tsc) to handle .ts files
// [ ] Rename entry point to .ts, fix immediate errors
// [ ] Add shared types package as dependency
// [ ] Migrate API route handlers (highest-value boundary)
// [ ] Migrate database layer (queries, entities, migrations)
// [ ] Migrate middleware and utility functions
// [ ] Enable noImplicitAny, fix all implicit any errors
// [ ] Enable strictNullChecks, add null handling
// [ ] Enable full strict: true
// [ ] Remove all @ts-expect-error comments
// [ ] Enable noUncheckedIndexedAccess for final safety pass
In production, we migrated 26 microservices from JavaScript to strict TypeScript over 4 months. We prioritized services by error frequency in production logs. The first 3 services migrated were responsible for 70% of runtime errors. After migration, those services saw a 60% reduction in production incidents. The investment paid for itself within the first month.

9. ECMAScript 2025 and TypeScript Roadmap

ECMAScript 2025 (finalized June 2025) brings significant additions to the JavaScript language, all fully supported in TypeScript. Combined with TypeScript's own roadmap (Project Corsa and TS 7.0), these changes reshape how we write typed JavaScript.

Iterator Helpers

ES2025 adds .map(), .filter(), .take(), .drop(), .reduce(), .toArray(), and more directly on iterators. These enable lazy evaluation chains without converting to arrays first, which is critical for processing large datasets or infinite sequences.

// Iterator Helpers (ES2025) - lazy evaluation on iterators
function* fibonacci(): Generator<number> {
  let [a, b] = [0, 1];
  while (true) { yield a; [a, b] = [b, a + b]; }
}

// Lazy: only computes what's needed
const result = fibonacci()
  .drop(5)           // skip first 5
  .filter(n => n % 2 === 0)  // only even
  .take(3)           // first 3 matching
  .toArray();        // materialize: [8, 34, 144]

Set Methods

ES2025 adds union(), intersection(), difference(), symmetricDifference(), isSubsetOf(), and isSupersetOf() to the Set prototype. No more manual iteration or conversion to arrays for set operations.

// Set methods (ES2025)
const frontend = new Set(['react', 'vue', 'angular', 'svelte']);
const used = new Set(['react', 'angular', 'nestjs', 'fastify']);

frontend.intersection(used);       // Set {'react', 'angular'}
frontend.union(used);              // Set {'react','vue','angular','svelte','nestjs','fastify'}
frontend.difference(used);         // Set {'vue', 'svelte'}
used.isSubsetOf(frontend);         // false
new Set(['react']).isSubsetOf(used); // true

Explicit Resource Management (using / await using)

The using and await using declarations provide automatic cleanup for resources like file handles, database connections, and locks. When the variable goes out of scope, its [Symbol.dispose]() or [Symbol.asyncDispose]() method is called automatically — similar to C#'s using or Python's with.

// Explicit Resource Management (ES2025 + TypeScript)
class DatabaseConnection implements Disposable {
  [Symbol.dispose]() {
    this.release(); // auto-called when leaving scope
  }
}

function processData() {
  using conn = getConnection();  // auto-released at end of block
  using lock = acquireLock('resource-1');
  conn.query('SELECT ...');
} // conn.release() and lock.release() called automatically

// Async version for I/O resources
async function streamFile() {
  await using file = await openFile('data.csv');
  for await (const line of file) { process(line); }
} // file handle closed automatically

Promise.try, RegExp.escape, Array.fromAsync, JSON Modules

Promise.try(fn) wraps synchronous-or-async functions into a Promise (replacing the new Promise(resolve => resolve(fn())) pattern). RegExp.escape(str) safely escapes special regex characters. Array.fromAsync() creates arrays from async iterables. JSON modules allow import data from './config.json' with { type: 'json' } for type-safe JSON imports.

// Promise.try - safe wrapper for sync-or-async functions
const result = await Promise.try(() => maybeThrows());

// JSON Modules - import with type assertion
import config from './config.json' with { type: 'json' };
// config is typed based on the JSON structure

The satisfies Operator (TS 4.9+)

The satisfies operator checks that a value conforms to a type without widening it. This is now a widely adopted pattern for configuration objects, route maps, and any scenario where you want both type checking and precise literal inference.

// satisfies: type-check without widening
type Route = { path: string; method: 'GET' | 'POST'; handler: Function };

const routes = {
  users:  { path: '/users',  method: 'GET',  handler: getUsers },
  create: { path: '/users',  method: 'POST', handler: createUser },
} satisfies Record<string, Route>;

// routes.users.method is inferred as 'GET' (not 'GET' | 'POST')
// Type error if you add a route with method: 'PUT' (not in Route)

TypeScript Roadmap: Project Corsa and TS 7.0

The TypeScript compiler is being rewritten in Go (Project Corsa), targeting 10x faster type-checking and builds. TypeScript 7.0 is expected mid-2026 with strict-by-default (no more "strict": true needed), ES5 target dropped, and the native Go compiler as the default. For new projects, update tsconfig.json target from ES2022 to ES2025 to access all new features.

// Updated tsconfig.json for modern Node.js (2025+)
{
  "compilerOptions": {
    "target": "ES2025",              // was ES2022 -- enables ES2025 features
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,                  // will be default in TS 7.0
    "verbatimModuleSyntax": true,
    "noUncheckedIndexedAccess": true,
    "erasableSyntaxOnly": true       // for native Node.js TS execution
  }
}
The combination of ES2025 features and TypeScript's evolving compiler represents a significant shift. Iterator helpers eliminate entire categories of intermediate array allocations. Explicit resource management (using) prevents resource leaks that previously required try/finally patterns. And with Project Corsa, the type-checking bottleneck in large monorepos will be dramatically reduced.

Latest Updates (April 2026)

TypeScript 5.8: erasableSyntaxOnly and Direct Node.js Execution

TypeScript 5.8 (March 2025) introduced the --erasableSyntaxOnly flag, which restricts code to TypeScript constructs that can be safely erased without runtime behavior -- prohibiting enums, namespaces, parameter properties, and legacy import forms that emit runtime code. This flag enables running TypeScript directly in Node.js 23.6+ with --experimental-strip-types (or Node.js 24+ where it is enabled by default), eliminating the need for ts-node or any compilation step in development. Combined with verbatimModuleSyntax, this creates a zero-build development workflow for pure TypeScript projects.

TypeScript 5.9: Import Defer and Expandable Hovers

TypeScript 5.9 (released Q1 2026) introduced import defer syntax for ECMAScript's deferred module evaluation proposal, giving fine-grained control over when module side-effects execute. The strictInference compiler flag tightens conditional type narrowing. Developer experience improved significantly with expandable hovers in editors (expand/collapse type information on hover) and configurable hover length via the language server. Build performance gains for large projects are measurable, with tighter integration with the upcoming Corsa native compiler.

TypeScript 7.0 / Project Corsa: 10x Faster Compilation

The TypeScript compiler rewrite in Go (Project Corsa) has made dramatic progress. Type checking is nearly complete, and benchmarks show 8-10x build speedups: the Sentry project dropped from 133 seconds to 16 seconds, and the VS Code codebase from 89 seconds to under 9 seconds. TypeScript 6.0 will be the last JavaScript-based major release. TypeScript 7.0, targeting mid-to-late 2026, will ship the native Go compiler as default, with strict-by-default (no more "strict": true needed) and the ES5 target dropped. The Corsa API is still evolving, and existing Strada-based tooling will need migration.

The TS 5.9 to 7.0 transition represents the most significant change in TypeScript history. For immediate action: adopt TS 5.9 and its import defer support, update tsconfig target to ES2025, and begin testing with the Corsa preview builds to identify API incompatibilities in your toolchain. The 10x speedup will be transformative for monorepos where type-checking currently takes minutes.

More Guides