Fixing TypeScript Strict Mode Inference and Generic Failures
Troubleshoot TypeScript strict mode errors and generic inference failures. Learn to sync Zod schemas with types and resolve migration breaks effectively.
Type 'unknown' is not assignable to type 'T'.
You have seen this. You define a generic function, pass an argument, and TypeScript gives up. It throws its hands in the air and admits it has no idea what your variable is. This isn't the compiler being stupid. It’s being cautious. Your generic constraints aren't doing the work you think they are.
The Cost of the Red Wall
Turning on strict mode in an existing project does more than enable a few flags. You are telling the compiler to stop guessing. With TypeScript 6.0 moving toward strict mode as the default, hiding behind sloppy types is a dead end. You lose the silent any coercions. You lose the lazy assumption that null is a valid value for an object.
Most teams hit a wall of hundreds of errors. The instinct is to sprinkle any or as assertions everywhere to make the build pass. Don't do that. You are trading a build-time nuisance for a runtime crash. Tighten your generic constraints instead.
Why Generics Infer as Unknown
The compiler fails to infer types because you haven't provided the necessary guarantees. Consider this standard pattern.
function getFirst<T>(items: T[]): T {
return items[0];
}
const user = getFirst([]); // Inferred as unknown, but technically undefinedIf your array is empty, getFirst returns undefined. In strict mode, undefined is not T. If T is User, then undefined is not a User. The compiler screams.
The fix isn't to force the type. The fix is to be honest about the return value.
function getFirst<T>(items: T[]): T | undefined {
return items[0];
}By explicitly declaring the undefined possibility, you force the caller to handle the empty case. Stop lying to the compiler about what your functions return.
Bridge the Trust Boundary with Zod
Static types evaporate at runtime. This is why you get an API response that doesn't match your interface, leading to the dreaded "Cannot read property of undefined."
Do not manually write interfaces for API responses. It’s a maintenance trap. Use Zod to define your schemas and infer the types from them. This ensures your static types stay in sync with your runtime data.
import { z } from 'zod';
const UserSchema = z.object({
id: z.string(),
email: z.string().email(),
});
type User = z.infer<typeof UserSchema>;
function handleUser(data: unknown) {
const result = UserSchema.safeParse(data);
if (!result.success) return;
// result.data is now typed as { id: string, email: string }
console.log(result.data.email);
}If the API changes, your Zod schema fails at the gate instead of deep inside your UI logic.
Solving Narrowing Conflicts with Satisfies
A common friction point occurs when you have an object that could be one of several types, but TypeScript isn't sure which one. You might try to use a constant, but then you lose the specific type narrowness.
The satisfies operator helps here. It validates that an expression matches a type without forcing the variable to become that broad type.
type Route = { path: string; component: string };
const routes = {
home: { path: '/', component: 'Home' },
dashboard: { path: '/dash', component: 'Dash' },
} satisfies Record<string, Route>;
// TypeScript still knows that routes.home.path is specifically '/'
// instead of just 'string'If you had used a standard type annotation, routes.home.path would have been widened to just string. satisfies keeps the narrow inference while ensuring structural integrity.
Managing Migration Breaks
If you are stuck with a legacy codebase, don't migrate in one massive pull request. Use a phased approach.
1. Enable strictNullChecks first. This is the biggest source of runtime errors. Fix these before touching anything else.
2. Use the allowJs and checkJs flags. Keep your existing JavaScript files. Start catching errors in them without needing to convert everything to TS overnight.
3. Targeted any removal. Use ts-migrate to identify where any is being used and replace them with unknown incrementally. unknown forces you to perform type checking before access.
When you upgrade to TypeScript 6.0, your moduleResolution settings might break. Check your tsconfig.json. Ensure you are using node16 or bundler for the module resolution strategy if you use modern ESM patterns. The old node strategy is often the silent killer after an upgrade.
When to Stop Being Clever
There is a point where strictness turns into cargo cult programming. If you find yourself writing complex conditional types, nested mapped types, and recursive generics that span fifty lines just to satisfy a "Type X is not assignable to Y" error, stop.
If the compiler needs a Herculean effort to understand your code, your human teammates won't understand it either. A simpler type signature is better than a genius-level generic that hides intent. If you have to fight the compiler for more than ten minutes, your abstraction is wrong. Simplify the logic. Let the types follow.
Code for the next engineer who has to read your PR, not for the compiler's ego.