50% off SaaS Starter Kit — only for the first 100 buildersGrab it →
← Back to blog
best-practicesMay 21, 2026·8 min read

TypeScript Strict Mode — Why You Should Turn It On and Keep It On

Enabling TypeScript's strict mode feels painful at first. But every error it surfaces is a bug you didn't know you had. Here's why we never build without it.

Ștefan Binisor

Ștefan Binisor

Co-founder, peal.dev

TypeScript Strict Mode — Why You Should Turn It On and Keep It On

We've worked on codebases where strict mode was off. Usually because someone started the project in a hurry, hit the first type error, and typed `// @ts-ignore` instead of fixing it. Six months later, the codebase is a minefield — null pointer exceptions in production, functions that accept anything and return something vaguely typed as `any`, and a team that's lost all trust in the type system. TypeScript without strict mode is just JavaScript with extra steps.

Turn on strict mode. Do it now. Yes, it'll surface a bunch of errors. Every single one of those errors is a real bug or a genuine ambiguity in your code. TypeScript is not lying to you.

What Does Strict Mode Actually Enable?

When you add `"strict": true` to your tsconfig.json, you're not flipping one switch — you're enabling a bundle of checks that TypeScript keeps separate for backwards compatibility reasons. Here's what you're actually turning on:

  • strictNullChecks — null and undefined are no longer assignable to every type. This one alone prevents a class of runtime crashes.
  • noImplicitAny — TypeScript can no longer silently infer `any` when it can't figure out a type. You have to be explicit.
  • strictFunctionTypes — function parameter types are checked contravariantly, which catches subtle callback bugs.
  • strictBindCallApply — bind, call, and apply are now properly type-checked instead of accepting anything.
  • strictPropertyInitialization — class properties must be initialized in the constructor or declared as optional.
  • noImplicitThis — `this` in a function without an explicit type annotation throws an error.
  • useUnknownInCatchVariables — caught exceptions are `unknown` instead of `any`, which forces you to actually check what you caught.

The most impactful ones in day-to-day Next.js work are `strictNullChecks` and `noImplicitAny`. They change how you think about data flow through your app.

The strictNullChecks Revelation

Before `strictNullChecks`, TypeScript would let you do this:

// Without strictNullChecks — TypeScript is fine with this
function getUserName(userId: string): string {
  const user = db.findUser(userId); // returns User | null
  return user.name; // 💥 Runtime crash if user is null
}

TypeScript would happily let you access `.name` on something that could be `null`. With strict mode on, this is a compile error. You're forced to handle the null case:

// With strictNullChecks — TypeScript catches the problem
function getUserName(userId: string): string {
  const user = db.findUser(userId); // returns User | null
  
  if (!user) {
    throw new Error(`User ${userId} not found`);
  }
  
  return user.name; // TypeScript now knows user is not null here
}

// Or if you want to return null too:
function getUserNameOrNull(userId: string): string | null {
  const user = db.findUser(userId);
  return user?.name ?? null;
}

This matters enormously in Next.js apps. Server actions return data that might be null. Route params exist or they don't. User sessions can expire. Without strict null checks, you're trusting yourself to remember all of that at 11pm when you're pushing a fix. You won't.

noImplicitAny Is Your Code Review That Never Sleeps

The other one that changes everything is `noImplicitAny`. When TypeScript can't infer a type, it normally just gives up and uses `any`. With this flag on, it refuses and makes you be explicit. This sounds annoying. It is, initially. But it forces a habit that saves you constantly.

// noImplicitAny catches this pattern that's surprisingly common
function processWebhookData(data) { // Error: 'data' implicitly has an 'any' type
  return data.payload.userId; // What if payload doesn't exist?
}

// You're forced to be explicit:
interface WebhookPayload {
  event: string;
  payload: {
    userId: string;
    timestamp: number;
  };
}

function processWebhookData(data: WebhookPayload) {
  return data.payload.userId; // TypeScript knows this exists
}

// Or if you genuinely don't know the shape yet:
function processWebhookData(data: unknown) {
  // Now you have to validate before accessing anything
  if (!isWebhookPayload(data)) {
    throw new Error('Invalid webhook payload');
  }
  return data.payload.userId;
}

That last pattern — using `unknown` instead of `any` — is what `useUnknownInCatchVariables` enforces for caught exceptions too. It's the right call. When you catch an error, you genuinely don't know what it is. It might be an Error instance, it might be a string someone threw, it might be an axios error with a completely different shape. Treating it as `unknown` forces you to actually check.

Turning It On in an Existing Codebase Without Losing Your Mind

If you're starting fresh, add `"strict": true` to your tsconfig.json and fix the errors as you go. That's the easy case. The harder case is an existing codebase with hundreds of errors suddenly surfacing. We've been there.

The approach that works: don't try to fix everything at once. Use a separate tsconfig for strict checks and migrate file by file. Here's a pattern we've used:

// tsconfig.json — your main config, not strict yet
{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": false,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
    "plugins": [{"name": "next"}],
    "paths": {
      "@/*": ["./*"]
    }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
  "exclude": ["node_modules"]
}

// tsconfig.strict.json — for checking strict compliance
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "strict": true
  }
}

Then add a script: `"typecheck:strict": "tsc --project tsconfig.strict.json --noEmit"`. Run it, pick the files with the fewest errors, fix those first. Add a comment at the top of each fixed file so you know it's been through strict review. Over a few sprints, you migrate the whole codebase. Not glamorous, but it works.

The goal isn't to have zero errors in strict mode before you start — it's to stop adding new non-strict code. Freeze the current debt, fix it file by file, don't let it grow.

Additional Flags Worth Enabling Beyond Strict

Strict mode is the foundation, but there are a few more flags we always add to our tsconfig that catch real bugs:

{
  "compilerOptions": {
    "strict": true,
    
    // These are not part of strict but we always add them:
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true
  }
}

`noUncheckedIndexedAccess` is particularly interesting. When you access `array[0]`, TypeScript normally types it as `T` — the array element type. But `array[0]` could be `undefined` if the array is empty. With this flag on, it's typed as `T | undefined`, which forces you to handle the empty case. We've caught real bugs with this one.

`exactOptionalPropertyTypes` is stricter about optional properties. Without it, `{ foo?: string }` means foo can be a string, undefined, or simply missing. With it, explicitly setting `foo: undefined` is different from not setting foo at all. This matters when you're building API responses and spreading objects.

The Real Cost — And Why It's Worth It

We're not going to pretend strict mode has zero cost. It does slow you down initially. Writing proper types takes time. Narrowing types instead of casting takes thought. But here's the thing: that time is not lost. It's shifted forward in time from "debugging production at 2am" to "compile time, before anyone's using it".

We once deployed a Next.js app without strict mode that had a subtle bug in a data transformation function. The function accepted `any`, did some reshaping, and returned `any`. Somewhere in the chain, a field that should have been a number was coming back as a string. The app didn't crash — it just silently displayed wrong numbers to users for three days before someone noticed. Strict mode + proper types would have caught that at build time.

The other real cost is library types. Some packages have mediocre types that break under strict mode. The usual fix is to either contribute better types upstream, use the DefinitelyTyped package if it exists, or wrap the library in your own typed interface. The wrapper approach is often the right call anyway — it decouples your codebase from the library's API surface.

If a library's types don't hold up under strict mode, that's information. Either the library is poorly maintained, or you're using it in ways that aren't type-safe. Both are worth knowing.

Strict Mode in Next.js Projects Specifically

Next.js actually generates a tsconfig with some sensible defaults, but `strict` isn't always true depending on how you initialize the project. Always check. For App Router projects especially, strict mode pairs well with how server components and server actions work — the types for `params`, `searchParams`, and async components are precise enough that strict mode actually helps you understand what Next.js expects from you.

// In App Router, params can be a Promise in newer Next.js versions
// Strict mode forces you to handle this correctly

// Page component with proper typing
interface PageProps {
  params: Promise<{ slug: string }>;
  searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}

export default async function BlogPost({ params, searchParams }: PageProps) {
  const { slug } = await params;
  const { page } = await searchParams;
  
  // page is string | string[] | undefined — strict mode makes you handle all cases
  const pageNumber = Array.isArray(page) ? parseInt(page[0]) : parseInt(page ?? '1');
  
  // TypeScript is satisfied. More importantly, your code is correct.
  return <div>Post: {slug}, Page: {pageNumber}</div>;
}

All the templates we ship at peal.dev have strict mode on by default, along with the additional flags above. Not because we like making your life harder, but because we've seen what happens to codebases six months in when strict mode was skipped. Every template starts with the right foundation so you don't have to retrofit it later.

The Practical Takeaway

Here's the minimum viable strict tsconfig for a Next.js App Router project:

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
    "plugins": [{"name": "next"}],
    "paths": {
      "@/*": ["./*"]
    }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
  "exclude": ["node_modules"]
}

Add `"typecheck": "tsc --noEmit"` to your package.json scripts and run it in CI. If TypeScript isn't checking your code in CI, it's not really checking it — it's just checking it on whoever's machine happened to run the linter last. That's not a safety net, that's a suggestion.

The bottom line: TypeScript strict mode is not an advanced feature for serious projects. It's the baseline. Non-strict TypeScript is a type system that's opted out of doing its job for the cases that matter most. Turn it on, fix the errors, and never look back. Your future self, debugging production at 2am, will thank you — or more accurately, won't have to.

Newsletter

Liked this post? There's more where it came from.

Dev guides, honest build stories, and the occasional 2am debugging confession — straight to your inbox. No spam, unsubscribe anytime.

Browse templates
Written by humansWeekly dropsSubscriber perks

Join the Discord

Ask questions, share builds, get help from founders