loke.dev
Header image for Fixing TypeScript Strict Mode Errors in Large Codebases
TypeScript Web Development Software Engineering

Fixing TypeScript Strict Mode Errors in Large Codebases

Troubleshoot TypeScript strict mode errors and generic inference failures. Learn to bridge runtime data with Zod for robust, type-safe production code.

Published 4 min read

Most tutorials frame TypeScript strict mode as a magic wand that fixes your codebase. This is a lie. When you flip strict: true in a legacy project, you aren't leveling up. You are triggering a massive, synthetic friction event. Your build fails. You get five thousand errors. Your team starts panic-casting to any just to get the CI green.

The compiler isn't attacking you. It’s finally telling the truth about the loose, implicit assumptions you made six months ago.

The wall of errors

The common shock is hitting the error where unknown isn't assignable to string. You’re probably fetching JSON and blindly casting it.

// The old, "fast" way that breaks in strict mode
const response = await fetch('/api/user');
const data = await response.json(); 
const userName: string = data.name; // Error: Type 'unknown' is not assignable

The error appears because unknown is the type-safe sibling of any. It forces you to prove what the data is before you touch it. Many devs try to fix this by using as any or as string. Don't. If you cast your way out of strict mode, you haven't fixed the errors. You’ve just hidden them until runtime, where they will crash the production app instead of the build.

How to debug generic inference failures

Generics often collapse into unknown or never when the compiler can't trace the relationship between your input and output. This happens frequently with complex mapped types.

Look at this pattern that frequently fails:

function getProperty<T, K extends keyof T>(obj: T, key: K) {
  return obj[key];
}

const user = { id: 1, name: 'Alice' };
const result = getProperty(user, 'email'); // Error: Argument of type 'string' is not assignable to parameter of type '"id" | "name"'

The compiler can't infer the constraint if the object is too loosely defined. If user is typed as a broad interface instead of a literal, the K extends keyof T constraint loses its grip.

When inference fails, stop trying to write a smarter generic. Explicitly pass the type arguments at the call site. It’s not a failure of skill to be explicit. It’s a requirement for clarity.

// Explicit is better than clever
const result = getProperty<User, 'name'>(user, 'name');

If you find yourself writing triple-nested generic types to satisfy the compiler, you are over-engineering. If the compiler needs a hint, give it the hint. Don't hide the complexity behind a recursive type that takes a senior engineer twenty minutes to parse.

Aligning runtime API data with Zod

The biggest cause of type drift is maintaining a TypeScript interface by hand and hoping it matches the backend response. It never does. Stop writing interfaces for API responses entirely.

Use Zod. Treat the Zod schema as your single source of truth.

import { z } from 'zod';

const UserSchema = z.object({
  id: z.string(),
  email: z.string().email(),
});

type User = z.infer<typeof UserSchema>;

async function fetchUser(id: string): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  const json = await response.json();
  
  // Now you validate at runtime. If the API changes, this throws.
  return UserSchema.parse(json); 
}

By using z.infer, the TypeScript type is a reflection of the validation logic. If you change the schema, the type updates automatically. You stop fighting the compiler about whether an API field is optional or nullable. The validator handles the narrowing for you.

Refactoring with satisfies

The satisfies operator is the best tool introduced in recent years for strict mode. It allows you to validate that an object matches a type without losing the specific literal values inside it.

Before satisfies, we often did this:

type Config = { port: number | string };
const config: Config = { port: 8080 }; 
// If I want to use config.port as a number, I now have to narrow it.

satisfies lets you have your cake and eat it too.

const config = { port: 8080 } satisfies Config;
// The compiler knows 'port' is exactly 8080, not just 'number | string'.

Use satisfies when you define configuration objects, mock data, or constants that need to fit a shape but retain their specific value type for narrowing.

When to stop

If you are fighting the compiler for more than thirty minutes, you are likely writing code that is too dynamic for TypeScript's current inference engine.

Do not reach for any. Do not write a massive recursive mapped type that uses infer three levels deep just to make a Partial<Record<...>> work.

Break the code into smaller, simpler functions. TypeScript handles linear, top-down logic perfectly. It chokes on clever functional chains that rely on complex inference. If you have to choose between a type that is mathematically perfect and a type that is readable, choose readability every time. Your teammates will thank you when they have to fix the next production bug.

Resources

- The TypeScript Handbook on Narrowing: typescriptlang.org/docs/handbook/2/narrowing.html
- Zod Documentation: zod.dev
- TypeScript 6.0 Release Notes: devblogs.microsoft.com/typescript
- Stack Overflow: How to properly narrow unknown types: stackoverflow.com
- JSManifest: Understanding type inference in strict mode: jsmanifest.com

Resources