- Published on
TypeScript Strict Mode in Large Codebases — Enabling It Without Breaking Everything
- Authors

- Name
- Sanjeev Sharma
- @webcoderspeed1
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
- Incremental Migration Strategy with @ts-strict-ignore
- Utility Types for Type-Safe Migration
- Discriminated Unions for Error Handling
- Template Literal Types for Type-Safe API Routes
- The satisfies Operator for Type Inference
- Branded Types for ID Safety
- Checklist
- Conclusion
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.