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

Sanjeev SharmaSanjeev Sharma
11 min read

Advertisement

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.

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro