NoWaterProgramming

TypeScript Best Practices: The tsconfig Flags and Patterns That Catch Real Bugs

Which strict flags earn their keep and what each one costs, when to reach for @ts-expect-error over @ts-ignore, why catch variables are unknown, and how to type the edges where outside data enters.

14 min read
Share:

Checked against TypeScript 5.9 on 2026-08-20.

Most "TypeScript best practices" lists are the handbook rearranged. This one is organised around a single question: which settings and patterns actually turn a runtime bug into a compile error, and what does each of them cost you in return.

The short version. strict: true is worth it on day one and there is no serious argument against it. The flags outside strict are where the trade-off is real, and where "as strict as possible" stops being good advice. Type narrowing beats assertions everywhere. And the place types pay for themselves most is the boundary where outside data arrives, which is also the place most codebases give up and write as.

These patterns apply in a Next.js app, in a component library, or on a Node backend.

Start With strict, Then Stop and Think

One flag turns on the bundle that matters:

{
  "compilerOptions": {
    "strict": true
  }
}
json

It enables strictNullChecks, noImplicitAny, strictFunctionTypes, strictBindCallApply, strictPropertyInitialization, noImplicitThis, useUnknownInCatchVariables and alwaysStrict.

strictNullChecks is the one that catches real bugs. Without it every type silently includes null and undefined:

// Without strictNullChecks: compiles, throws at runtime
function getLength(str: string) {
  return str.length;
}
 
getLength(null); // no compile error
typescript

With it on, the compiler makes you handle the case:

function getLength(str: string | null): number {
  if (str === null) return 0;
  return str.length;
}
typescript

Migrating an existing codebase? Turn on strictNullChecks first and the rest afterwards. It produces the most errors and the highest ratio of real bugs among them.

Why catch (e) gives you unknown

useUnknownInCatchVariables ships inside strict and surprises people, usually as the error "Catch clause variable type annotation must be 'any' or 'unknown' if specified". The reason is simple: JavaScript lets you throw anything. A string, a number, undefined. Typing the caught value as Error is a guess, and it is wrong often enough to matter, particularly around third-party code and rejected promises.

try {
  await save(record);
} catch (e) {
  // e is unknown. Narrow before using it.
  const message = e instanceof Error ? e.message : String(e);
  logger.error(message);
}
typescript

Annotating catch (e: any) to make the error go away gives back exactly the bug the flag exists to prevent.

The Flags Outside strict, and What They Cost

This is where the usual advice ("enable everything") goes wrong. Each of these catches something real and charges you for it.

noUncheckedIndexedAccess. Adds | undefined to every array index and index-signature read. It is correct: arr[10] on a three-element array is undefined, and TypeScript otherwise lies about that.

const first = items[0];   // with the flag: Item | undefined
first.name;               // now an error, correctly
typescript

The cost is that it does not distinguish a genuinely unchecked lookup from a loop over 0..length-1 where you already know the element exists. In array-heavy code you end up writing non-null assertions to silence it, and a codebase full of ! is not safer than one without the flag.

exactOptionalPropertyTypes. Distinguishes a missing property from one explicitly set to undefined. Note the spelling: it is exactOptionalPropertyTypes, not exactOptionalProperties, which is the name people reach for and then wonder why the flag does nothing. It matters when you serialise, and it is noisy when you build objects incrementally.

noImplicitReturns, noFallthroughCasesInSwitch, noPropertyAccessFromIndexSignature. Cheap, low-noise, worth turning on.

For what it is worth, this blog's tsconfig.json runs strict: true and stops there. Not because the other flags are wrong, but because the codebase is small and reads a lot of arrays, and noUncheckedIndexedAccess was producing more assertions than it was catching bugs. That is the honest trade-off; "as strict as possible" is a slogan, not a config.

We later put a number on that claim: we turned both flags on across five of our codebases and counted every error. The cost of noUncheckedIndexedAccess varied 24x between them, which is the argument for measuring your own repo rather than taking anyone's recommendation, including this one.

Narrow, Do Not Assert

as tells the compiler to trust you. Narrowing lets it verify. Prefer narrowing every time:

// Bad: you are overriding the compiler
function processValue(value: unknown) {
  return (value as string).toUpperCase(); // throws if it is not a string
}
 
// Good: each branch is checked
function processValue(value: unknown): string {
  if (typeof value === "string") return value.toUpperCase();
  if (typeof value === "number") return value.toFixed(2);
  throw new Error(`Unexpected value type: ${typeof value}`);
}
typescript

Discriminated unions

When an object has several shapes, give them a shared literal field and TypeScript narrows on it automatically:

type RequestState =
  | { status: "loading" }
  | { status: "success"; data: User[] }
  | { status: "error"; message: string };
 
function renderState(state: RequestState) {
  switch (state.status) {
    case "loading":
      return "Loading...";
    case "success":
      return `Found ${state.data.length} users`;
    case "error":
      return `Error: ${state.message}`;
  }
}
typescript

This is the pattern behind request state, API envelopes, reducer actions and result types. It is also what makes exhaustiveness checking work: give the function a return type and the compiler flags any case you forget.

@ts-expect-error, Not @ts-ignore

Both silence the next line. Only one of them tells you when the suppression is no longer needed.

// @ts-ignore
legacy.doThing(); // silent forever, including after the type is fixed
 
// @ts-expect-error - upstream types are wrong, see issue #412
legacy.doThing(); // errors if this line ever stops erroring
typescript

@ts-expect-error fails the build when the underlying error disappears, so the suppression gets deleted during the upgrade that fixed it instead of outliving the problem by three years. Use it as the default, always with a reason on the same line, and reserve @ts-ignore for the rare case where an error appears only on some TypeScript versions you support.

How often this choice actually comes up is another question. When we counted the suppression comments in five of our codebases, the total was one @ts-expect-error and zero @ts-ignore across 31,128 lines: under strict, the type checker is rarely wrong about the program in a way you have to argue with.

Interfaces for Shapes, Types for Everything Else

interface User {
  id: string;
  name: string;
  email: string;
  role: "admin" | "editor" | "viewer";
}
 
type ApiResponse<T> = { data: T; error: null } | { data: null; error: string };
type UserRole = User["role"];
typescript

Interfaces support declaration merging, which is how you extend third-party types. Types cover unions, intersections, mapped and conditional types. Pick one default for object shapes and stay consistent; the difference is smaller than the number of words spent arguing about it.

Generics, and When They Are Not Worth It

A generic preserves the type through a function instead of flattening it:

function firstElement<T>(arr: T[]): T | undefined {
  return arr[0];
}
 
const num = firstElement([1, 2, 3]);       // number | undefined
const str = firstElement(["a", "b", "c"]); // string | undefined
typescript

Constrain with extends when the function needs a property:

interface HasId { id: string }
 
function findById<T extends HasId>(items: T[], id: string): T | undefined {
  return items.find((item) => item.id === id);
}
typescript

The signal that you want a generic is duplication: two near-identical functions or types that differ only in the data they carry. The signal that you have gone too far is a signature you have to read twice. A generic that saves ten lines and costs every future reader thirty seconds is a bad trade, and this is the most common way TypeScript gets worse instead of better.

// Unreadable
type Props = Omit<React.ComponentPropsWithoutRef<"button">, "onChange"> & {
  onChange: (value: string) => void;
} & (
  | { variant: "primary"; destructive?: never }
  | { variant: "danger"; destructive: true }
);
 
// Same thing, named
type BaseButtonProps = Omit<React.ComponentPropsWithoutRef<"button">, "onChange">;
type CustomProps = { onChange: (value: string) => void };
type PrimaryVariant = { variant: "primary"; destructive?: never };
type DangerVariant = { variant: "danger"; destructive: true };
 
type Props = BaseButtonProps & CustomProps & (PrimaryVariant | DangerVariant);
typescript

The Built-In Utility Types

type UpdateUserPayload = Partial<User>;
type UserPreview = Pick<User, "id" | "name">;
type UserWithoutDates = Omit<User, "createdAt">;
type ImmutableUser = Readonly<User>;
type UsersByRole = Record<User["role"], User[]>;
 
// Composition is where they earn their keep
type UpdateUser = Pick<User, "id"> & Partial<Omit<User, "id">>;
type CreateUser = Omit<User, "id" | "createdAt">;
typescript

These map onto operations you already have. Your update endpoint takes a partial object with a required id. Your create endpoint excludes server-generated fields. Say that in the types instead of writing three near-identical interfaces.

Type the Edges, Because That Is Where Data Lies

API responses, form input, environment variables, URL params, localStorage, anything read off disk. Inside those boundaries your types are guarantees; outside them they are wishes. This is also the honest answer to "is TypeScript enough for safe code": it is not, because a type annotation is erased before the untrusted value ever arrives.

We went and checked how well we follow our own advice here: 371 escape hatches audited across five codebases, 87 of them sitting on untrusted input. The count turned out to matter far less than how far each assertion sits from the check it depends on.

Parse, do not cast

import { z } from "zod";
 
const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(1).max(100),
  email: z.string().email(),
  role: z.enum(["admin", "editor", "viewer"]),
});
 
type User = z.infer<typeof UserSchema>;
 
async function getUser(id: string): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  return UserSchema.parse(await response.json()); // throws if the shape is wrong
}
typescript

One declaration produces both the runtime check and the static type, so they cannot drift apart. Compare that with await response.json() as User, which type-checks perfectly and tells you nothing.

Environment variables

const envSchema = z.object({
  DATABASE_URL: z.string().url(),
  API_KEY: z.string().min(1),
  NODE_ENV: z.enum(["development", "production", "test"]),
  PORT: z.coerce.number().default(3000),
});
 
export const env = envSchema.parse(process.env);
typescript

env.DATABASE_URL is now string rather than string | undefined, and a missing variable fails at startup with a readable message instead of at 3am with undefined is not a valid URL.

A real boundary, from this codebase

This site's post index is generated to JSON at build time, because Cloudflare Workers have no filesystem at runtime. The generator writes absent frontmatter fields as null; the domain type uses optional properties. Two representations of "not set", and they are not assignable to each other.

The lazy fix is a cast. What the code does instead is reconcile once, in one function, at the boundary:

interface GeneratedPost {
  slug: string;
  title: string;
  author: string | null;
  image: string | null;
  canonical: string | null;
  // ...
}
 
function toPostMetadata(raw: GeneratedPost): PostMetadata {
  return {
    slug: raw.slug,
    title: raw.title,
    // ...
    ...(raw.author ? { author: raw.author } : {}),
    ...(raw.image ? { image: raw.image } : {}),
    ...(raw.canonical ? { canonical: raw.canonical } : {}),
  };
}
typescript

The conditional spread adds the key only when there is a value, so an unset field is genuinely absent rather than present-and-undefined. Every consumer downstream gets the clean domain type, and there is exactly one place to look when the generator changes shape. It is more code than as PostMetadata[]. It is also the only version that would have failed loudly when the generator started emitting null instead of omitting the key.

Anti-Patterns Worth Naming

any as an escape hatch. Use unknown and narrow. any does not just skip checking on that value, it disables checking on everything derived from it.

// Bad
function handleEvent(event: any) {
  console.log(event.target.value);
}
 
// Good
function handleEvent(event: unknown) {
  if (event instanceof Event && event.target instanceof HTMLInputElement) {
    console.log(event.target.value);
  }
}
typescript

Enums by reflex. TypeScript enums emit runtime code, and numeric enums allow reverse lookups that hide typos. A union or a const object usually says the same thing for free:

type Status = "active" | "inactive" | "pending";
 
// Or, when you need the values at runtime:
const STATUS = {
  Active: "active",
  Inactive: "inactive",
  Pending: "pending",
} as const;
 
type Status = (typeof STATUS)[keyof typeof STATUS];
typescript

Treating types as tests. Types remove the "what if this is null" tests. They do not tell you the function computes the right answer.

What TypeScript Costs

A best-practices post that only lists wins is an advert. The real costs, in rough order of how often they bite:

  • Type annotations are erased, so runtime safety is not included. The common claim that TypeScript has "zero runtime overhead" is nearly true and not quite: enum, constructor parameter properties and legacy decorators all emit JavaScript. Everything else disappears. Either way, erasure means nothing validates the data crossing your boundaries unless you write that validation yourself.
  • Migration is not free. Turning on strictNullChecks in a large JavaScript codebase produces thousands of errors, most of which are noise around code that has worked for years. Doing it file by file, with @ts-expect-error on the leftovers, is slower and finishes.
  • Type-level cleverness becomes technical debt. Conditional and mapped types can express almost anything. The person debugging your inference chain in eighteen months will not thank you.
  • Build steps and tooling drift. Every TypeScript minor version has changed inference somewhere. Pin it, upgrade deliberately, and expect a handful of new errors each time.
  • strict will not stop the bug you actually shipped, if that bug was a wrong as, a lie in a .d.ts, or an API that changed shape without telling you.

Run tsc --noEmit in CI regardless. It is the cheapest of these costs and it catches all of the mechanical ones.

FAQ

What is the single most important TypeScript setting?

strict: true, and within it strictNullChecks. Null and undefined handling is the largest category of runtime error that types can eliminate outright. Everything else on a best-practices list is smaller than this one flag.

Should I enable every strict flag?

No. Enable strict on day one. Then treat noUncheckedIndexedAccess and exactOptionalPropertyTypes as separate decisions with real costs: the first adds | undefined to every index access, which is correct but noisy in array-heavy code, and the second distinguishes missing from undefined, which is helpful when serialising and irritating when building objects incrementally. noImplicitReturns and noFallthroughCasesInSwitch are cheap and worth it.

When should I use @ts-ignore?

Almost never. Use @ts-expect-error with a comment explaining why, because it fails the build once the underlying error is fixed, which means the suppression gets removed. Keep @ts-ignore for the case where the error only appears on some of the TypeScript versions you support, so an expect-error would itself fail elsewhere.

Why is my catch variable typed as unknown?

Because useUnknownInCatchVariables is part of strict, and because JavaScript can throw any value, not only Error instances. Narrow it with e instanceof Error before touching .message. Annotating catch (e: any) restores exactly the unsoundness the flag removes.

Does TypeScript slow down my code?

Type annotations are erased at compile time, so the emitted JavaScript is what you would have written by hand. Three features are exceptions and do emit runtime code: enum, constructor parameter properties, and legacy decorators. Build times are longer than plain JavaScript, by an amount that depends on your compiler and project size.

What is the difference between type and interface?

Interfaces describe object shapes, support declaration merging, and extend with extends. Types additionally cover unions, intersections, primitives, mapped types and conditional types. For plain object shapes they are close to interchangeable. Pick a default and be consistent.

How do I migrate a JavaScript project to TypeScript?

Rename files one at a time, start with strict: false, and turn flags on incrementally. Do the most-imported modules first, because typing a shared utility improves inference everywhere it is used. Use @ts-expect-error with a reason for anything you are deferring, so the suppressions surface again when they become unnecessary.

Sources

Checked 2026-08-20.

Related Posts

13 min read
We counted every @ts-ignore, @ts-expect-error and @ts-nocheck in five production codebases. The total was one. The same 31,128 lines hold 304 type assertions, and only one of those two kinds of opt-out tells you when it has gone stale.
By NoWaterProgramming Team
18 min read
We used the TypeScript compiler API to count every place five production codebases tell the type system to stop checking, then read every one that sits on untrusted input. The count turned out to be the wrong thing to worry about.
TypeScriptsecurity
By NoWaterProgramming Team
12 min read
We turned on noUncheckedIndexedAccess and exactOptionalPropertyTypes across five production codebases and counted every error. The cost per thousand lines varied by 24x, and half the work was not where you index.
By NoWaterProgramming Team