Type checking

Status: 🟩 COMPLETE Last updated: 2026-06-19 Plain-English tagline: Running TypeScript’s compiler over your code to catch entire categories of bugs — typos, wrong shapes, mismatched arguments — before the code ever runs.


In plain English

Plain JavaScript will happily let you write:

function greet(user) {
  return "Hello, " + user.naem;   // typo
}
 
greet("George");                  // wrong argument type — string, not object

…and there is NO complaint. Run the code. It executes. user.naem is undefined. The greeting becomes "Hello, undefined". The bug is shipped. The user reports it three days later.

Type checking is the practice of running a program over your source code that REASONS about what the code does — what shape each variable has, what arguments each function expects — and shouts when something doesn’t match. The same buggy code in TypeScript with a tight config:

function greet(user: { name: string }) {
  return "Hello, " + user.naem;   // ❌ error: Property 'naem' does not exist on type '{ name: string; }'.
}
 
greet("George");                  // ❌ error: Argument of type 'string' is not assignable to parameter of type '{ name: string; }'.

Both bugs caught at the moment you save the file. The code can’t compile. The bug never runs.

The mechanism: TypeScript’s compiler (tsc) reads your source, builds a model of every type that flows through the program, and verifies that uses match definitions. When done right, it eliminates a vast class of “stupid mistakes” — typos in property names, wrong argument orders, forgotten null checks, returning the wrong shape from a function — that would otherwise be runtime bugs.

In the Bible Quest-style stack, every file is .ts or .tsx. Type checking happens continuously in the editor and as a final CI gate. The result: an entire class of bug that used to be common in JavaScript codebases is essentially gone.


Why it matters

Three concrete reasons type checking is THE highest-leverage quality layer:

  1. Catches bugs at write-time. A unit test catches bugs the moment you run it. A type check catches them the moment you SAVE. The feedback loop is in milliseconds, before you even reach for the test runner.

  2. It’s “free” testing. You don’t write extra test code. The types you write to describe your data ALSO serve as ongoing verification. A type definition is a test that runs continuously over every line of code that touches the type.

  3. It makes refactoring fearless. Rename a property. Add a required field to an interface. Change a function’s signature. Type checking finds every call site that broke. No more “I refactored a thing and missed three places” weeks later.

Studies of large codebases (Airbnb famously found 38% of their JS bugs would have been caught by TypeScript) confirm what daily users feel: a meaningful fraction of bugs are pure type errors that types prevent.

The trade-off: types add upfront effort. Writing the types for an API response, a database row, a component’s props takes time. The payoff is huge for projects you’ll maintain; smaller for one-off scripts.

For solo development on long-running projects: always TypeScript. The investment compounds.


How tsc works

The TypeScript compiler (tsc) does two things:

  1. Type-checks your code against the types you’ve defined
  2. Transpiles TypeScript to JavaScript (strips types, sometimes downlevels syntax)

In modern setups, you rarely run tsc for transpilation — bundlers (Vite, Turbopack, esbuild, Webpack) do that faster, often without type-checking. So tsc becomes a TYPE-CHECK-ONLY tool:

npx tsc --noEmit

The --noEmit flag tells tsc “don’t write any JS output, just check types.” This is the canonical type-check command for any modern project.

tsc typically runs in three places:

  • In your editor (VS Code’s TypeScript service), live, on every keystroke
  • As a pre-commit hook (via lint-staged or husky)
  • In CI, on every PR, as a build gate

When all three run, type errors are caught at write-time, before commit, and before merge. The wall is thick.


A concrete example: the types working for you

Suppose you have a database model:

type Post = {
  id: string;
  title: string;
  body: string;
  authorId: string;
  publishedAt: Date | null;     // null means draft
  tags: string[];
};

Now a function that takes a Post and renders its title prefix:

function formatTitle(post: Post): string {
  const date = post.publishedAt.toLocaleDateString();   // ❌ Object is possibly 'null'.
  return `[${date}] ${post.title}`;
}

TypeScript catches the bug instantly: publishedAt can be null (drafts), and you’d crash at runtime if you called .toLocaleDateString() on null. The compiler forces you to handle the null case:

function formatTitle(post: Post): string {
  const date = post.publishedAt
    ? post.publishedAt.toLocaleDateString()
    : "Draft";
  return `[${date}] ${post.title}`;
}

That’s the value: a category of “I forgot null was possible” bugs becomes impossible.

Push deeper: change Post to add a slug field:

type Post = {
  id: string;
  slug: string;     // ← new required field
  title: string;
  // ...
};

Every place in your codebase that creates a Post (without a slug) now has a type error. Run tsc --noEmit — TypeScript lists every file that needs updating. You go fix them. The refactor is mechanical.

In a JavaScript-only codebase, that same change would silently leave slug undefined in many places, and you’d discover the issues weeks later when a specific code path hit them.


The TypeScript config — tsconfig.json

A typical Next.js project’s tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["dom", "dom.iterable", "esnext"],
    "module": "esnext",
    "moduleResolution": "bundler",
    "strict": true,                                // ← the most important line
    "noEmit": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "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"]
}

The key flag is "strict": true, which enables a bundle of stricter checks:

FlagWhat it catches
noImplicitAnyVariables typed as any by default (forces you to be explicit)
strictNullChecksNull/undefined cannot be assigned to non-nullable types
strictFunctionTypesFunction parameter type compatibility is stricter
strictBindCallApply.bind(), .call(), .apply() are type-checked
strictPropertyInitializationClass properties must be initialized in the constructor
noImplicitThisthis cannot be implicitly any
useUnknownInCatchVariablescatch (e) typed as unknown, not any
alwaysStrictTreats files as ES strict mode

If you only enable ONE thing in TypeScript, enable "strict": true. The looser variants are mostly there for migrating old JavaScript codebases.

Some other useful flags worth knowing:

  • "noUncheckedIndexedAccess": true — accessing an array index gives T | undefined instead of just T. Catches “off-by-one in a loop” bugs.
  • "exactOptionalPropertyTypes": true{ name?: string } means missing OR string, not undefined explicitly.
  • "noFallthroughCasesInSwitch": true — switch case without break is an error.
  • "noUnusedLocals": true — declared but never used.
  • "noUnusedParameters": true — function param never used.

The standard 2026 default for new projects: "strict": true + "noUncheckedIndexedAccess": true. Add the others as you find them useful.


The type-checking workflow

In a modern editor (VS Code), TypeScript runs CONSTANTLY:

  • On every keystroke, the TypeScript language service re-checks the file
  • Errors appear as red squiggles
  • Hovering shows the inferred or declared types
  • “Go to Definition” navigates by type information
  • “Rename Symbol” propagates renames across all type-aware references
  • Autocomplete uses the types to suggest valid properties / arguments

In the terminal:

npx tsc --noEmit              # One-off type check
npx tsc --noEmit --watch      # Continuous (rebuild on file changes)

The watch mode is great when refactoring large changes — keep it running in a separate terminal, see errors appear as you save files.

In CI:

- run: npx tsc --noEmit

Three lines: install deps, build, type-check. The build catches some errors; the explicit tsc --noEmit catches the rest (especially in projects using Vite/Turbopack that skip type-check during fast bundling).


Common type-checking patterns

Narrowing — the bread and butter

TypeScript narrows types through if, typeof, instanceof, and explicit checks:

function process(input: string | number) {
  if (typeof input === "string") {
    return input.toUpperCase();   // input is `string` here
  }
  return input * 2;               // input is `number` here
}

The compiler is impressively good at following control flow. You can write idiomatic code and let TypeScript track the narrowing.

Discriminated unions

A pattern that makes type-checking elegant:

type Result =
  | { status: "success"; data: User }
  | { status: "error"; error: string };
 
function render(r: Result) {
  if (r.status === "success") {
    console.log(r.data.name);    // r.data exists here
  } else {
    console.log(r.error);        // r.error exists here
  }
}

The status field discriminates. TypeScript narrows the type based on which branch you’re in. No runtime checks needed — the compiler proves the access is safe.

Const assertions

const role = "admin" as const;       // type: "admin" (not "string")
 
const config = {
  endpoint: "/api/users",
  method: "POST",
} as const;                          // each field is its literal type

Useful when you want a specific literal type to “stick” — e.g. for action types, role enums, config objects.

unknown over any

any opts out of type checking entirely; unknown requires you to narrow before use:

async function fetchData(url: string): Promise<unknown> {
  const res = await fetch(url);
  return res.json();
}
 
const data = await fetchData("/users/1");
// data.name              // ❌ Object is of type 'unknown'.
 
if (typeof data === "object" && data && "name" in data) {
  // data is narrowed; (data as any).name works, but better:
  // use Zod or similar to validate the actual shape
}

For real input validation, pair unknown with Zod:

import { z } from "zod";
 
const User = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string().email(),
});
 
const data = await fetchData("/users/1");
const user = User.parse(data);       // throws if invalid; typed if not
console.log(user.name);              // typed!

This is the standard pattern: TypeScript for STATIC type-checking; Zod (or Valibot, ArkType, etc.) for RUNTIME validation at boundaries. Together they give you “trust your types inside the codebase; verify at the edges.”


Type inference — let the compiler figure it out

You don’t have to annotate every variable. TypeScript infers types based on what you assign:

const name = "George";           // inferred as string
const count = 42;                // inferred as number
const tags = ["a", "b", "c"];    // inferred as string[]
 
function double(n: number) {
  return n * 2;                  // return type inferred as number
}

The general rule: annotate at the boundaries (function parameters, exported values, public APIs); let inference do the rest.

export function calculateShipping(weight: number, country: string): number {
  // Inside the function, inference works:
  const isHeavy = weight > 5;          // boolean
  const baseRate = country === "AU" ? 15 : 30;  // number
  return isHeavy ? baseRate + 10 : baseRate;
}

Over-annotating is noise. Under-annotating at boundaries makes APIs harder to understand. The middle ground is the right ground.


Generic types — types that take types

For reusable, type-safe code:

function first<T>(items: T[]): T | undefined {
  return items[0];
}
 
const a = first([1, 2, 3]);          // number | undefined
const b = first(["x", "y"]);          // string | undefined
const c = first([{ name: "G" }]);     // { name: string } | undefined

The <T> is a type parameter. It changes based on what’s passed in. The function is fully type-safe for any input type. This is how things like Array.map<T>(), Promise<T>, useState<T>() work.

Generics make abstract code typed; they’re the difference between any (no safety) and a parameterized type (full safety, full generality).


TypeScript meets the rest of the stack

For Bible Quest-style projects, TypeScript shows up everywhere:

  • React components — props are typed; state is typed via useState<T>()
  • API routes / Server Actions — input is validated with Zod; the output type flows to the client
  • Database client (Supabase / Prisma / Drizzle) — generates TypeScript types from your schema; queries return typed results
  • External APIs — SDKs (Anthropic, Stripe) ship their own types
  • Next.js itself — exports a vast type system for App Router conventions

The result: from URL to database column to React render, you have one continuous chain of types. Refactoring is mechanical. Auto-completion is everywhere. Bugs that survive into production are typically NOT type errors — they’re logic errors that need real tests.


When TypeScript doesn’t help

Things types cannot catch:

  • Wrong logic with correct types. function add(a: number, b: number) { return a - b; } — types are fine, the logic is wrong.
  • Unhandled async errors. A promise that rejects still rejects regardless of types.
  • External data drift. Your type says the API returns { id: string }. The API actually returns { id: number }. TypeScript trusts you. Use Zod at the boundary.
  • Performance. A function with great types can still be O(n²).
  • Bugs in as casts. data as User tells TypeScript “trust me.” If you’re wrong, runtime breaks.
  • Bugs in // @ts-ignore. Same problem.

This is why types are necessary but not sufficient. Pair with linting, unit tests, integration tests, and validation at boundaries.


Common gotchas

  • any is contagious and corrosive. Once a value is typed any, every operation on it produces any, every value derived from it is any. One any can silently disable type-checking across a chunk of code. Use unknown instead and narrow.

  • as casts are escape hatches. data as User means “trust me, this is a User.” If you’re wrong, TypeScript doesn’t help. Reserve for genuine type-system limitations, not for “the compiler is wrong” (it usually isn’t).

  • // @ts-ignore should make you uncomfortable. It silences errors but doesn’t fix them. Use sparingly with a comment explaining; prefer // @ts-expect-error which fails if the error eventually goes away (so you remove the directive).

  • tsc and editor TypeScript can disagree. If your editor uses a newer TS version than the project’s, you’ll see different errors. Lock the TypeScript version and configure the editor to use the workspace version.

  • tsc --noEmit is fast in watch mode, slow cold. A large project takes 20–60 seconds for a fresh check. Watch mode incrementally rechecks only changed files. Use it during refactors.

  • paths in tsconfig needs bundler support. If you set "paths": { "@/*": ["./*"] }, both TypeScript AND your bundler must understand it. Most modern bundlers do; some configurations don’t.

  • Type-only imports save bundle size. import type { Post } from "./types" is erased at compile-time — no runtime cost. Use it for types you never instantiate.

  • unknown is the right type for JSON.parse. TypeScript types it as any by default; override or wrap with a parser like Zod.

  • Promise<void> is correct; Promise<undefined> is not. void means “you shouldn’t use the return value”; undefined means “the value is undefined.” Subtle but matters.

  • enum is mostly outdated. Use union types (type Role = "admin" | "user") instead — simpler, no runtime emit, tree-shakable.

  • Record<string, T> is permissive. It allows any string key, including ones you didn’t mean. Use specific union keys when you can: Record<"a" | "b" | "c", T>.

  • Optional vs undefined. { name?: string } and { name: string | undefined } differ subtly. With exactOptionalPropertyTypes, they differ MORE. Default to optional (?) for missing-vs-present semantics; explicit undefined for “always present but possibly nothing.”

  • Strict mode requires you to handle nullability. A project that disables strictNullChecks lets someValue.toUpperCase() slip through even when someValue might be undefined. Always run strict.

  • Inferred types can be wider than you think. A literal ["a", "b"] is inferred as string[], not ["a", "b"]. Use as const to keep literals.

  • any from third-party packages spreads. Old packages with no types or weak types poison your types. Either add a proper type (declare module) or wrap them at the boundary.

  • @ts-expect-error is better than @ts-ignore. It causes a build failure if the underlying error eventually goes away, prompting you to remove the directive.

  • Build vs type-check timing. Next.js’s build may skip strict type-checks for speed. Run tsc --noEmit explicitly in CI to be sure.

  • Type predicates and assertion functions can be wrong. A function declared as value is User can return true for non-users — TypeScript trusts your return value. Use Zod’s safeParse for runtime-truthful narrowing.

  • Function type is essentially any. Don’t use it. Type the specific function shape: (x: number) => string.

  • Object (capital O) is rarely what you want. Use Record<string, unknown>, {}, or specific shapes.

  • Generic constraints can be invisible. function f<T extends User>(x: T) only accepts subtypes of User. Errors about “doesn’t satisfy constraint T extends User” point at this.

  • Module declaration files (.d.ts) can drift. Hand-written type declarations for a JS package can become outdated as the package evolves. Prefer packages that ship their own types.

  • tsconfig.json is hierarchical. A tsconfig.json in a subfolder extends from the parent. Be aware of inheritance when debugging “why doesn’t strict mode apply here?”

  • Strict mode breaks many old packages. When enabling strict on a JS-to-TS migration, expect hundreds of errors. Use // @ts-expect-error with deadlines to ratchet down.

  • React.FC is largely deprecated. Modern React doesn’t recommend it. Type props directly: function Counter({ initial }: { initial?: number }).

  • Pure type changes can break runtime if you use as. A safe-looking type change like renaming a field cascades into casts that were silently wrong.

  • The error messages can be hairy. A complex generic error message might be 30 lines deep. Read the FIRST error, not the cascade — the first one is usually the real problem.

  • tsc doesn’t run on .js files unless you opt in. allowJs: true enables it; without that, JS files are type-unchecked.

  • You can have BOTH .js and .ts files. Useful during migration; confusing if not deliberate. Pick one for new code.

  • TypeScript versions break things. Major releases (5.0, 5.5) tighten checks; expect some new errors. Pin TypeScript to a specific version and upgrade deliberately.


When NOT to use TypeScript

Edge cases where TypeScript is more friction than help:

  • One-file utility scripts that run once and get deleted
  • Quick prototypes you’ll throw away within hours
  • Code where the runtime is highly dynamic (some plugin systems)

For everything else George builds: always TypeScript, always "strict": true.


See also


Sources