The compiler
tsc does two things: it checks the types and it emits JavaScript. Most projects only want the first, because a bundler — Vite, esbuild, swc — does the second far faster by stripping the types without checking them at all.
npm i -D typescript
npx tsc --init # writes tsconfig.json
npx tsc # check, and emit .js
npx tsc --noEmit # check only — what CI should run
npx tsc --watch # check on save
Which means the build does not check anything
{
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"typecheck": "tsc --noEmit --watch"
}
}
esbuild and swc erase types without reading them. npm run dev will happily run code full of type errors. Put tsc --noEmit in your scripts and in CI, or the checker is decoration.
tsconfig.json, the parts that matter
strict— the one that matters. It turns on eight flags at once, and a project without it is checking about half of what it could.target— how far down the syntax is compiled. Match what you actually support.lib— which built-in type definitions exist. Without"DOM",documentis unknown.skipLibCheck— do not type-check your dependencies’ declarations. Saves seconds, costs nothing.moduleResolution: "bundler"for anything bundled;"nodenext"for Node.
{
"compilerOptions": {
"strict": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM"],
"jsx": "react-jsx",
"noEmit": true,
"skipLibCheck": true,
"esModuleInterop": true,
"verbatimModuleSyntax": true
},
"include": ["src"]
}
What strict actually turns on
strictNullChecks is the one that finds real bugs on the first day you turn it on. Without it, door.name.trim() on a missing door is accepted; with it, you are asked what happens when it is not there.
| Flag | What it stops |
|---|---|
noImplicitAny | A parameter with no type quietly becoming any |
strictNullChecks | null and undefined being assignable to everything |
strictFunctionTypes | Unsound parameter substitution in callbacks |
strictPropertyInitialization | A class field that is never assigned |
noImplicitThis | this being any in a loose function |
useUnknownInCatchVariables | Assuming a caught error is an Error |
Worth adding beyond strict
{
"noUncheckedIndexedAccess": true, // arr[0] is T | undefined, because it might be
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"exactOptionalPropertyTypes": true // { a?: string } is not the same as { a: string | undefined }
}
Adding a strictness flag to a young codebase is an afternoon. Adding one to a two-year-old codebase is a quarter.
Types for packages
import express from "express"; // ships its own types — nothing to do
npm i -D @types/node // types published separately
// and for a package with none at all:
// src/types/untyped-thing.d.ts
declare module "untyped-thing"; // everything from it is any, but it imports
Reading an error
A TypeScript error is a chain of reasoning: the first line is the conclusion and the last is the cause. Read from the bottom. For deep generic errors, ts-error-translator in the editor turns the worst of them into English.
Argument of type '{ name: string; }' is not assignable to parameter of type 'Door'.
Property 'days' is missing in type '{ name: string; }' but required in type 'Door'.
// ↑ the second line is the actual problem