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:
-
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.
-
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.
-
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:
- Type-checks your code against the types you’ve defined
- 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 --noEmitThe --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:
| Flag | What it catches |
|---|---|
noImplicitAny | Variables typed as any by default (forces you to be explicit) |
strictNullChecks | Null/undefined cannot be assigned to non-nullable types |
strictFunctionTypes | Function parameter type compatibility is stricter |
strictBindCallApply | .bind(), .call(), .apply() are type-checked |
strictPropertyInitialization | Class properties must be initialized in the constructor |
noImplicitThis | this cannot be implicitly any |
useUnknownInCatchVariables | catch (e) typed as unknown, not any |
alwaysStrict | Treats 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 givesT | undefinedinstead of justT. Catches “off-by-one in a loop” bugs."exactOptionalPropertyTypes": true—{ name?: string }means missing OR string, not undefined explicitly."noFallthroughCasesInSwitch": true— switch case withoutbreakis 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 --noEmitThree 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 typeUseful 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 } | undefinedThe <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
ascasts.data as Usertells 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
-
anyis contagious and corrosive. Once a value is typedany, every operation on it producesany, every value derived from it isany. Oneanycan silently disable type-checking across a chunk of code. Useunknowninstead and narrow. -
ascasts are escape hatches.data as Usermeans “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-ignoreshould make you uncomfortable. It silences errors but doesn’t fix them. Use sparingly with a comment explaining; prefer// @ts-expect-errorwhich fails if the error eventually goes away (so you remove the directive). -
tscand 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 --noEmitis 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. -
pathsin 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. -
unknownis the right type forJSON.parse. TypeScript types it asanyby default; override or wrap with a parser like Zod. -
Promise<void>is correct;Promise<undefined>is not.voidmeans “you shouldn’t use the return value”;undefinedmeans “the value is undefined.” Subtle but matters. -
enumis 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. WithexactOptionalPropertyTypes, they differ MORE. Default to optional (?) for missing-vs-present semantics; explicitundefinedfor “always present but possibly nothing.” -
Strict mode requires you to handle nullability. A project that disables
strictNullChecksletssomeValue.toUpperCase()slip through even whensomeValuemight be undefined. Always run strict. -
Inferred types can be wider than you think. A literal
["a", "b"]is inferred asstring[], not["a", "b"]. Useas constto keep literals. -
anyfrom 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-erroris 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 --noEmitexplicitly in CI to be sure. -
Type predicates and assertion functions can be wrong. A function declared as
value is Usercan returntruefor non-users — TypeScript trusts your return value. Use Zod’ssafeParsefor runtime-truthful narrowing. -
Functiontype is essentiallyany. Don’t use it. Type the specific function shape:(x: number) => string. -
Object(capital O) is rarely what you want. UseRecord<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.jsonis hierarchical. Atsconfig.jsonin 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-errorwith deadlines to ratchet down. -
React.FCis 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.
-
tscdoesn’t run on.jsfiles unless you opt in.allowJs: trueenables it; without that, JS files are type-unchecked. -
You can have BOTH
.jsand.tsfiles. 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
- Why test? 🟩 — broader quality story
- Unit tests 🟩 — what types DON’T catch
- Integration tests 🟩
- End-to-end (E2E) tests 🟩
- Linting 🟩 — TypeScript + ESLint together cover the most ground
- Code review 🟩 — humans catch what types and lint miss
- TypeScript 🟩 — the language itself
- JavaScript 🟩 — what TS compiles to
- Next.js 🟩 🟦 — TS-first by default
- CD 🟩 —
tsc --noEmitas a CI gate - APIs — the big picture 🟩 — types at boundaries
- Server actions (Next.js) 🟩 🟦 — typed RPC
- Glossary: Type, Generic, tsc, Zod
Sources
- TypeScript Handbook — canonical reference
- TypeScript Cheatsheet (React) — community-maintained pattern reference
tsconfigreference — every compiler flag explained- Total TypeScript — Matt Pocock’s deep TS material
- Effective TypeScript (book) — Dan Vanderkam’s 62 specific recommendations
- Zod — the standard runtime validator