Published on

TypeScript Strict Mode in Large Codebases — Enabling It Without Breaking Everything

Authors

Introduction

Strict mode catches real bugs. But enabling it in large codebases causes thousands of errors. This post covers incremental migration: targeted suppression, utility types, proper error handling patterns, and leveraging strict checks for production reliability.

What Strict Mode Enables and Why It Matters

Strict mode enables 6 important checks.

// Without strict mode (permissive)
function example(x: any): void {
  const y: string = x; // Allowed: any is permissive
  const z: string | null = 'hello';
  const length = z.length; // Allowed: null not checked
  const obj = { name: 'alice' };
  obj.age = 30; // Allowed: any property
}

// With strict mode
// ✓ strictNullChecks: null/undefined must be explicit
// ✓ noImplicitAny: no implicit any types
// ✓ strictFunctionTypes: contravariance in function params
// ✓ strictBindCallApply: bind/call/apply type-checked
// ✓ strictPropertyInitialization: class props must initialize
// ✓ noImplicitThis: no implicit any for this

// tsconfig.json
{
  "compilerOptions": {
    "strict": true,
    // Equivalent to setting all above to true
  }
}

tsconfig.json with individual flags:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ES2020",
    "lib": ["ES2020"],
    "strict": true,
    "strictNullChecks": true,
    "noImplicitAny": true,
    "strictFunctionTypes": true,
    "strictBindCallApply": true,
    "strictPropertyInitialization": true,
    "noImplicitThis": true,
    "alwaysStrict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true
  }
}

Incremental Migration Strategy with @ts-strict-ignore

Enable strict mode but suppress errors in specific files during migration.

// Step 1: Enable strict mode globally
// tsconfig.json
{
  "compilerOptions": {
    "strict": true
  }
}

// Step 2: Suppress strict checks in non-critical files
// tsconfig.json
{
  "compilerOptions": {
    "strict": true,
    "skipLibCheck": true
  },
  "exclude": ["node_modules"]
}

// For individual files, use comment-based suppression
// OLD FILE: needs migration (suppress errors)
// @ts-nocheck - suppresses all errors in file

// OR: file-level strict ignore (better: allow gradual fixes)
// Old patterns that we'll fix incrementally

// Step 3: Migrate critical paths first
// auth/
// user-service/
// api/
// utils/

// Then less critical
// legacy-features/
// deprecated/

// Step 4: Use targeted suppression for difficult parts
// @ts-ignore - suppress next line only
// @ts-expect-error - suppress and error if not needed (enforces removal)

Strategic suppression example:

// database.ts
// @ts-expect-error Legacy ORM returns any
const result = orm.query('SELECT * FROM users');

// vs (better):
interface QueryResult {
  rows: Array<{ id: number; email: string }>;
}

const result = (orm.query('SELECT * FROM users') as unknown) as QueryResult;

// Even better: migrate ORM to typed version
import { db } from './typed-db';

const result = await db.users.findAll();
// No assertion needed, fully typed

Utility Types for Type-Safe Migration

Use utility types to gradually add type information.

// NonNullable: remove null/undefined
type NonNullableString = NonNullable<string | null>;
// = string

function processString(s: NonNullableString): string {
  return s.toUpperCase(); // Safe, never null
}

// Required: make optional properties required
interface User {
  id: number;
  email?: string;
  name?: string;
}

type StrictUser = Required<User>;
// All properties required

// Partial: make all optional
type OptionalUser = Partial<User>;

// Pick: select specific properties
type UserPreview = Pick<User, 'id' | 'name'>;

// Omit: exclude properties
type UserWithoutEmail = Omit<User, 'email'>;

// Parameters: extract function parameters
function login(email: string, password: string): Promise<void> {}
type LoginParams = Parameters<typeof login>;
// = [email: string, password: string]

// ReturnType: extract return type
type LoginReturn = ReturnType<typeof login>;
// = Promise<void>

// Readonly: make properties immutable
type ReadonlyUser = Readonly<User>;
const user: ReadonlyUser = { id: 1, email: 'a@ex.com' };
// user.email = 'b@ex.com'; // Error!

// Record: create object with specific keys
type Permissions = Record<'read' | 'write' | 'admin', boolean>;
const perms: Permissions = {
  read: true,
  write: false,
  admin: false,
};

Practical migration example:

// BEFORE: loose types
function fetchUser(id: any): any {
  return fetch(`/api/users/${id}`).then((r) => r.json());
}

// STEP 1: add parameter type
function fetchUser(id: string | number): any {
  return fetch(`/api/users/${id}`).then((r) => r.json());
}

// STEP 2: define return interface
interface User {
  id: number;
  email: string;
  name: string;
}

function fetchUser(id: string | number): Promise<User> {
  return fetch(`/api/users/${id}`).then((r) => r.json());
}

// STEP 3: use utility types for variations
type UserPreview = Pick<User, 'id' | 'name'>;
type OptionalUser = Partial<User>;

async function fetchUserPreview(id: number): Promise<UserPreview> {
  const user = await fetchUser(id);
  return { id: user.id, name: user.name };
}

Discriminated Unions for Error Handling

Replace exception throwing with typed Result types.

// BEFORE: exceptions
function validateEmail(email: string): string {
  if (!email.includes('@')) {
    throw new Error('Invalid email');
  }
  return email;
}

// Usage requires try/catch
try {
  const email = validateEmail(input);
  console.log('Valid:', email);
} catch (err) {
  console.log('Error:', err.message);
}

// AFTER: discriminated union (Result type)
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };

interface ValidationError {
  field: string;
  message: string;
}

function validateEmail(
  email: string
): Result<string, ValidationError> {
  if (!email.includes('@')) {
    return {
      ok: false,
      error: { field: 'email', message: 'Missing @' },
    };
  }

  return { ok: true, value: email };
}

// Usage: exhaustive type checking
const result = validateEmail(input);

if (result.ok) {
  console.log('Valid:', result.value);
} else {
  console.log('Error:', result.error.message);
}

// Chaining with flatMap
function flatMap<T, E, U>(
  result: Result<T, E>,
  fn: (value: T) => Result<U, E>
): Result<U, E> {
  return result.ok ? fn(result.value) : result;
}

// Composition
function validateUserForm(data: any): Result<
  { email: string; age: number },
  ValidationError
> {
  const emailResult = validateEmail(data.email);
  if (!emailResult.ok) return emailResult;

  const ageResult = validateAge(data.age);
  if (!ageResult.ok) return ageResult;

  return {
    ok: true,
    value: {
      email: emailResult.value,
      age: ageResult.value,
    },
  };
}

// Real example: async operations
type AsyncResult<T, E> = Promise<Result<T, E>>;

async function fetchAndValidate(
  url: string
): AsyncResult<{ email: string }, { code: string }> {
  try {
    const response = await fetch(url);
    const data = await response.json();

    if (!response.ok) {
      return { ok: false, error: { code: response.statusText } };
    }

    const emailResult = validateEmail(data.email);
    return emailResult;
  } catch (err) {
    return {
      ok: false,
      error: { code: 'NETWORK_ERROR' },
    };
  }
}

// Usage
const result = await fetchAndValidate('https://api.example.com/user');
if (result.ok) {
  console.log('Success:', result.value.email);
} else {
  console.error('Failed:', result.error.code);
}

Template Literal Types for Type-Safe API Routes

Define routes as types to catch typos.

// Type-safe route handler registration
type RouteMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';

interface Route {
  method: RouteMethod;
  path: string;
  handler: (req: any, res: any) => void;
}

// Define routes as constants
const ROUTES = {
  GET_USER: 'GET /users/:id',
  POST_USER: 'POST /users',
  GET_POSTS: 'GET /users/:id/posts',
  DELETE_POST: 'DELETE /posts/:id',
} as const;

type RoutePath = typeof ROUTES[keyof typeof ROUTES];

// Extract type from route string
type ExtractMethod<T extends string> = T extends `${infer M} ${string}`
  ? M extends RouteMethod
    ? M
    : never
  : never;

type ExtractPath<T extends string> = T extends `${string} ${infer P}`
  ? P
  : never;

// Type-safe route registration
function registerRoute<T extends RoutePath>(
  route: T,
  handler: (req: any, res: any) => void
): void {
  const method = route.split(' ')[0] as RouteMethod;
  const path = route.split(' ')[1];
  console.log(`Registered ${method} ${path}`);
}

// Usage: autocomplete and type-checking
registerRoute('GET /users/:id', (req, res) => {
  // Typos caught at compile time
  // registerRoute('GTE /users/:id', ...); // Error: GTE not recognized
});

// Real example: API client type-safety
interface ApiEndpoints {
  'GET /users/:id': { response: { id: number; email: string } };
  'POST /users': {
    request: { email: string };
    response: { id: number };
  };
  'DELETE /posts/:id': { response: void };
}

function apiCall<K extends keyof ApiEndpoints>(
  endpoint: K,
  data?: ApiEndpoints[K] extends { request: infer R } ? R : never
): Promise<ApiEndpoints[K] extends { response: infer R } ? R : never> {
  // Type-safe: K must be valid endpoint
  // data type matches request if present
  // return type matches response
  return fetch(`/api${endpoint as string}`, {
    method: endpoint.split(' ')[0],
    body: data ? JSON.stringify(data) : undefined,
  }).then((r) => r.json());
}

// Usage
const user = await apiCall('GET /users/1');
// user type: { id: number; email: string }

const newUser = await apiCall('POST /users', { email: 'alice@example.com' });
// newUser type: { id: number }

The satisfies Operator for Type Inference

Use satisfies to check a value against a type while preserving literal types.

// PROBLEM: as Type loses specific literal types
const config = {
  mode: 'production' as 'production' | 'development',
  port: 3000 as number,
} as const;
// config.mode: 'production' | 'development' (lost literal)

// BETTER: satisfies preserves literals
type Config = {
  mode: 'production' | 'development';
  port: number;
};

const config = {
  mode: 'production',
  port: 3000,
} satisfies Config;
// config.mode: 'production' (literal preserved!)

// Use case 1: validate object shape without widening
const routes = {
  home: '/',
  about: '/about',
  contact: '/contact',
} satisfies Record<string, string>;

// Now routes.home is literal '/' not string
type Routes = typeof routes;
// { readonly home: '/'; readonly about: '/about'; readonly contact: '/contact'; }

// Use case 2: ensure all enum cases handled
enum Status {
  PENDING = 'pending',
  SUCCESS = 'success',
  ERROR = 'error',
}

const handlers = {
  [Status.PENDING]: (data) => console.log('Pending:', data),
  [Status.SUCCESS]: (data) => console.log('Success:', data),
  [Status.ERROR]: (data) => console.log('Error:', data),
} satisfies Record<Status, (data: any) => void>;
// All Status values must be keys

// Use case 3: constrain but don't widen
type Permissions = 'read' | 'write' | 'admin';

const userPerms = ['read', 'write'] satisfies readonly Permissions[];
// userPerms[0]: 'read' (literal)

// Without satisfies:
const badPerms: Permissions[] = ['read', 'write'];
// badPerms[0]: Permissions (union, not literal)

Branded Types for ID Safety

Use branded types to prevent mixing IDs of different types.

// PROBLEM: IDs are easily mixed up
function getUser(userId: number): Promise<{ id: number; name: string }> {
  return fetch(`/api/users/${userId}`).then((r) => r.json());
}

function getPost(postId: number): Promise<{ id: number; title: string }> {
  return fetch(`/api/posts/${postId}`).then((r) => r.json());
}

const userId = 123;
const postId = 456;

// Easy to mix them up:
getUser(postId); // TypeScript allows, but wrong!
getPost(userId); // TypeScript allows, but wrong!

// SOLUTION: branded types
type UserId = number & { readonly brand: 'UserId' };
type PostId = number & { readonly brand: 'PostId' };

// Helper functions to create branded types
function createUserId(id: number): UserId {
  return id as UserId;
}

function createPostId(id: number): PostId {
  return id as PostId;
}

// Now strict typing prevents mixing
function getUser(userId: UserId): Promise<{ id: UserId; name: string }> {
  return fetch(`/api/users/${userId}`).then((r) => r.json());
}

function getPost(postId: PostId): Promise<{ id: PostId; title: string }> {
  return fetch(`/api/posts/${postId}`).then((r) => r.json());
}

// Usage
const userId = createUserId(123);
const postId = createPostId(456);

getUser(userId); // ✓ Correct
getUser(postId); // ✗ Error: PostId not assignable to UserId

// Real example: email, phone branded types
type Email = string & { readonly brand: 'Email' };
type PhoneNumber = string & { readonly brand: 'PhoneNumber' };

function validateEmail(email: string): Email | null {
  if (email.includes('@')) {
    return email as Email;
  }
  return null;
}

function createUser(
  email: Email,
  phone: PhoneNumber
): Promise<{ id: number; email: Email; phone: PhoneNumber }> {
  return fetch('/api/users', {
    method: 'POST',
    body: JSON.stringify({ email, phone }),
  }).then((r) => r.json());
}

// Enforce validation
const email = validateEmail(input);
if (!email) throw new Error('Invalid email');

// Now email is guaranteed valid type
const user = await createUser(email, phoneNumber);

Checklist

  • ✓ Enable strict mode in tsconfig.json (all flags)
  • ✓ Use @ts-expect-error for intentional suppression (enforces removal)
  • ✓ Migrate critical paths first: auth, database, API
  • ✓ Define proper interfaces instead of using any
  • ✓ Use discriminated unions (Result<T, E>) instead of throwing
  • ✓ Use utility types (NonNullable, Required, Pick, Omit) for type variations
  • ✓ Use satisfies operator to preserve literal types
  • ✓ Use branded types for ID safety (UserId vs PostId)
  • ✓ Enable noUnusedLocals and noUnusedParameters for cleanup
  • ✓ Enable noImplicitReturns to ensure all code paths return

Conclusion

Strict mode isn't all-or-nothing. Migrate incrementally: enable it, suppress errors strategically, and fix critical paths first. Discriminated unions, branded types, and utility types transform TypeScript from a type checker into a design tool. Types become documentation and compile-time verification of correctness.