Strict Null Checks in TypeScript: What the Compiler Won’t Tell You and Where It Actually Hurts in Production
I was reviewing a Server Action in Next.js — something that compiled without a single error, clean types, green lint — when a Cannot read properties of undefined (reading 'id') hit in runtime. Three minutes of retrospective later I understood the problem: the compiler had given me the green light and I believed it. That was a mistake.
My thesis, straight up: strict null checks is necessary but not sufficient. The TypeScript compiler is the first filter in the system, not the last. Real null safety comes from runtime validation at the edges of the system — and there are four concrete patterns where the compiler says OK and production says otherwise.
This isn’t a “turn on strict: true and you’re done” post. It’s a map of where the compiler fails silently, using the Next.js 16 + Prisma ORM 5 + strict TypeScript stack as a concrete reference.
Strict Null Checks in TypeScript Production: What the Flag Actually Activates
When you enable strict: true in tsconfig.json, TypeScript turns on a more restrictive set of checks. According to the official docs, strict is a shorthand that includes, among others:
-
strictNullChecks—nullandundefinedare not assignable to other types without an explicit guard. -
noImplicitAny— no variable can be left without an inferred type. -
strictFunctionTypes— function types are checked contravariantly.
// tsconfig.json — recommended base configuration
{
"compilerOptions": {
"strict": true,
"target": "ES2022",
"lib": ["ES2022"],
"moduleResolution": "bundler"
}
}
Enter fullscreen mode Exit fullscreen mode
What strict does not do is verify that data arriving from the outside — an API, a JSON.parse, a database response, an HTTP header — actually has the shape the type declares. The compiler works with static types; runtime works with real data. Two different worlds, and the gap between them is exactly where bugs live.
The 4 Patterns Where the Compiler Says OK and Runtime Blows Up Anyway
Pattern 1 — Badly Typed Assertion Functions
Assertion functions are functions the compiler treats as type guards. If you declare them wrong, TypeScript trusts them blindly.
// ⚠️ Assertion function that doesn't do what it promises
function assertDefined<T>(val: T | null | undefined): asserts val is T {
// You forgot the throw — TypeScript won't catch this
// The compiler still marks val as T after this call
if (val === null || val === undefined) {
console.warn("null value detected"); // log without throw
}
}
const userId: string | null = getUserId();
assertDefined(userId);
// After this, TypeScript believes userId is string
// But if it was null, the console.warn didn't stop the flow
console.log(userId.toUpperCase()); // TypeError in runtime
Enter fullscreen mode Exit fullscreen mode
The compiler accepts the asserts val is T contract without checking the function body. If the assertion doesn’t throw, the type is lying. The fix is simple but not obvious:
// ✅ Correct assertion function — the throw is mandatory
function assertDefined<T>(val: T | null | undefined): asserts val is T {
if (val === null || val === undefined) {
throw new Error(`Required value was null or undefined`);
}
}
Enter fullscreen mode Exit fullscreen mode
Pattern 2 — Libraries with Imprecise Types or Implicit any
Plenty of ecosystem libraries publish types in @types/ that don’t always reflect actual return values. The most common case: a function typed as string | undefined that returns null in certain codepaths, or the other way around.
// Example with a hypothetical cookie parsing library
import { parseCookie } from "some-cookie-lib";
const sessionId: string = parseCookie(req.headers.cookie, "session");
// The lib is typed as string — but it can return null at runtime
// TypeScript doesn't complain because it trusts the declared type
Enter fullscreen mode Exit fullscreen mode
The warning sign is when you see as string scattered around, or when a library returns a broad type like any or Record<string, unknown>. At that point, the compiler delegates responsibility to whatever type you declare — and if that type is optimistic, you’ve already lost.
Checklist for external libraries:
Signal in the types Risk What to doany return
High
Validate with Zod at point of use
Outdated @types/ types
Medium
Check the lib’s CHANGELOG
`string
undefined when it could be null`
Medium
Auto-generated types (OpenAPI, etc.)
Variable
Validate at the entry boundary
Pattern 3 — Optional Prisma ORM 5 Relations
This one has surprised me the most working with Prisma. When you have an optional relation in the schema — user User? — Prisma types it as User | null. So far so good. The problem shows up when you do an include and then try to access the relation without having selected that field.
// schema.prisma
// model Post {
// id Int @id
// author User? @relation(fields: [authorId], references: [id])
// authorId Int?
// }
// ❌ The compiler accepts this — runtime can blow up
const post = await prisma.post.findUnique({
where: { id: 1 },
// No include of author
});
// TypeScript infers post.author as User | null | undefined
// based on the generated type — but if you didn't include it,
// author simply doesn't exist on the returned object
if (post?.author?.name) {
console.log(post.author.name); // undefined at runtime, not null
}
Enter fullscreen mode Exit fullscreen mode
Prisma 5 generates types that reflect the schema, but not the exact shape of each query. If you don’t include the relation in include, the field doesn’t come back in the object — and the generated type doesn’t express that with enough granularity. The fix:
// ✅ Explicit result typing with the include
const post = await prisma.post.findUnique({
where: { id: 1 },
include: { author: true }, // now the type correctly includes author
});
// TypeScript now knows post.author can be User | null (optional relation)
// and forces you to guard it before using it
if (post && post.author) {
console.log(post.author.name);
}
Enter fullscreen mode Exit fullscreen mode
The practical rule: in Prisma, the generated type reflects the schema, not the query. Always make your include/select match what the downstream code expects to consume.
Pattern 4 — JSON.parse Without Runtime Validation
This is the most classic one and the most underestimated. JSON.parse returns any in TypeScript — the compiler has no idea what shape that JSON has until runtime.
// ❌ The compiler accepts this completely
async function getConfiguration(): Promise<{ timeout: number; endpoint: string }> {
const raw = await fs.readFile("config.json", "utf-8");
return JSON.parse(raw); // returns any — TypeScript trusts the declared return type
}
const config = await getConfiguration();
// config.timeout could be undefined, string, null — the compiler doesn't know
const ms = config.timeout * 1000; // NaN or TypeError at runtime
Enter fullscreen mode Exit fullscreen mode
The solution is to validate at the boundary. Zod is the tool that fits best in this stack:
// ✅ Validation with Zod at the external data entry point
import { z } from "zod";
const ConfigSchema = z.object({
timeout: z.number().positive(),
endpoint: z.string().url(),
});
async function getConfiguration() {
const raw = await fs.readFile("config.json", "utf-8");
const parsed = JSON.parse(raw);
return ConfigSchema.parse(parsed); // throws ZodError if shape doesn't match
}
// Now the inferred type is exactly { timeout: number; endpoint: string }
// and runtime guarantees the shape before the data reaches the rest of the code
const config = await getConfiguration();
const ms = config.timeout * 1000; // safe
Enter fullscreen mode Exit fullscreen mode
The same pattern applies to Server Actions in Next.js that receive form data, to external API responses, and to any data that crosses the system boundary.
Common Mistakes When Configuring Strict Null Checks
Three mistakes show up constantly when teams enable strict on an existing codebase:
1. Turning off individual checks to make it compile
// ❌ This defeats the entire purpose of strict
{
"compilerOptions": {
"strict": true,
"strictNullChecks": false
}
}
Enter fullscreen mode Exit fullscreen mode
If a check breaks too much existing code, the right path is to migrate progressively with annotated and dated // @ts-expect-error comments — not to disable the flag globally.
2. Using the non-null assertion operator (!) without a real guard
// ❌ The ! operator tells the compiler "trust me"
// but does zero verification at runtime
const name = user!.name; // TypeError if user is null
Enter fullscreen mode Exit fullscreen mode
Every ! in the codebase is potential technical debt. If you see more than five ! in a single file, that’s a signal that the types aren’t accurately modeling the domain’s reality.
3. Confusing strict in Next.js config with strict in tsconfig
next.config.js has a typescript.ignoreBuildErrors option that, when set to true, completely bypasses the compiler during the build. The strict in tsconfig.json means nothing if the build never fails on type errors.
Decision Checklist: Where to Validate and Where to Trust the Compiler
Before deciding whether to add runtime validation or trust the static type, run through this checklist:
Question Yes No Does the data come from outside the process? (API, file, DB, form) Validate with Zod Compiler is enough Does the library haveany types or outdated @types/?
Add explicit guard
Compiler is enough
Are you using custom assertion functions?
Verify they throw
—
Is the Prisma relation in the include?
Type is precise
Add defensive guard
Does the type use ! to suppress a null?
Revisit the domain model
—
Rule of thumb: if the data crossed a system boundary (network, disk, form, environment variable), validate at runtime. If the data is internal to the process and the type was inferred by TypeScript, the compiler is enough.
Limits of This Guide
What you can’t conclude from this post without more evidence:
- How many production bugs come from each pattern — that depends on the specific codebase, test coverage, and team maturity.
- Whether Zod is always the best option over alternatives like Valibot or ArkType — there are bundle size and ergonomics trade-offs that deserve their own analysis.
- Whether these patterns apply equally in a codebase using tRPC or GraphQL with codegen — those systems have their own validation layers that change the equation.
What you can conclude: the four patterns are reproducible, have concrete solutions, and apply directly to the Next.js 16 + Prisma 5 + strict TypeScript stack.
FAQ — Strict Null Checks TypeScript Production
With strict: true enabled, can I trust there are no nulls at runtime?
No. strict: true guarantees the compiler warns you when a type can be null or undefined — but it can’t verify data coming in from outside the process. Data from APIs, forms, files, and databases needs additional runtime validation.
Does Prisma ORM generate types that exactly reflect what each query returns?
Partially. Prisma 5 infers the type from the schema and from the query’s include/select. If you don’t include a relation, the field won’t be on the returned object — but the generated type may not express that with enough precision in all cases. The safe practice is to always make the include match what downstream code consumes.
When does it make sense to use // @ts-expect-error instead of properly fixing the type?
Only in two cases: when you’re progressively migrating a legacy codebase to strict (annotated with a comment explaining why and an expected resolution date), or when you’re deliberately testing an error. In stable production code, @ts-expect-error without justification is technical debt with an unknown expiry date.
Does JSON.parse always return any?
Yes, by design. TypeScript can’t know the shape of the JSON until runtime. The only way to recover a concrete type is to validate the result with a library like Zod or write manual type guards. Manual guards don’t scale well; Zod scales better.
Are assertion functions a bad practice?
Not necessarily. They’re a legitimate tool in TypeScript’s type system. The problem is using them without a real throw — in that case, the contract you declare isn’t fulfilled at runtime and the compiler can’t detect it. With a proper throw, they’re a clean way to do imperative narrowing.
Does it make sense to migrate to strict null checks in a large codebase that doesn’t have it?
Yes, but with a strategy. The practical approach is to enable strict: true and use annotated @ts-expect-error to silence existing errors, then resolve them module by module — prioritizing system boundaries first (APIs, parsers, DB adapters) — and never disable strictNullChecks individually just to make it compile faster.
The Compiler Is the First Filter, Not the Last
Working with strict TypeScript in Next.js 16 and Prisma 5 changed how I think about type safety. Not as a binary “it compiled = it’s safe” but as a chain: the compiler filters static errors, runtime validation filters errors at the boundaries, and integration tests cover the rest.
The four patterns in this post — assertion functions without throw, libraries with imprecise types, optional Prisma relations without include, and JSON.parse without validation — have one thing in common: they all pass the compiler and they can all fail at runtime. The difference between teams that catch these before production and those that don’t is systematic: the first group puts validation at the boundary and doesn’t assume the compiler solves what it can’t see.
My practical stance: every time data enters the system from outside, Zod or equivalent. Every assertion function with a real throw. Every Prisma include reflecting what the downstream code actually needs. And zero ! operators without a real guard behind them.
If you’re working with a TypeScript codebase that mixes strict and legacy patterns, the concrete next step is to find every JSON.parse without validation and start there — it’s the most common boundary and the easiest one to fix first.
If you want to go deeper on system boundaries with TypeScript, I have related posts that can add context: DeepSeek API in TypeScript, Node.js and the event loop as a stack component, and Docker healthchecks in production all touch on the difference between what the system promises and what it delivers.
Original sources:
- TypeScript Handbook — Strict Mode: https://www.typescriptlang.org/tsconfig#strict
- Zod Documentation: https://zod.dev/
This article was originally published on juanchi.dev
답글 남기기