typescript
TypeScript is a static type layer on top of JavaScript plus the compiler that checks it. You write .ts files, the tsc compiler verifies the types and emits plain readable JavaScript that runs anywhere JS runs. It is a dev-time tool: types vanish at runtime, so it catches wrong arguments, nulls and typos before you ship, not after. Version 7 is a ground-up native rewrite of the compiler focused on speed; the language itself is unchanged.
The default for any JavaScript codebase that has to be maintained, and the 260M weekly downloads say the argument is over. Adopt strict mode from day one; the only real caution right now is the 7.0 compiler transition if you rely on compiler-API tooling.
Use it if
- The codebase is touched by more than one person or will live longer than a month; types are the cheapest documentation you will ever write
- You consume big third-party APIs (AWS SDK, React, Node) and want autocomplete and compile-time errors instead of runtime surprises
- You maintain a public library; shipping .d.ts types is table stakes for adoption in 2026
- You are refactoring a large JS codebase and want the compiler to find every call site you broke
- You are writing a throwaway script or tiny prototype; a build step and tsconfig ceremony slow you down for zero payoff, and JSDoc comments with checkJs get you 80% of the safety with no compiler
- Your tooling depends on the TypeScript 5.x compiler API (custom transformers, ts-morph style codegen); the 7.0 native compiler changes that surface and the old repo now only takes a narrow class of fixes, so audit your toolchain before upgrading
- Your team will fight the checker with any and as casts everywhere; badly typed TypeScript is worse than honest JavaScript because it lies with confidence
Setup reality
npm install -D typescript then npx tsc --init, and the compiler itself just works. The pain is tsconfig: module vs moduleResolution (nodenext vs bundler) confuses everyone, ESM/CJS interop errors are cryptic, and untyped dependencies need @types packages or hand-written declarations. Also note the 6/7 transition: development moved to the typescript-go repo and the original repo only accepts limited fixes, so some ecosystem tools lag behind 7.0.
Patterns
Install and create a tsconfiginstall-and-init
npm install -D typescript
npx tsc --init
npx tsc --noEmit # type-check without emitting JStsc --init writes a heavily commented tsconfig; keep strict true and delete options you do not understand yet.
A sane tsconfig for modern Nodestrict-tsconfig
{
"compilerOptions": {
"target": "es2022",
"module": "nodenext",
"strict": true,
"skipLibCheck": true,
"noUncheckedIndexedAccess": true,
"outDir": "dist"
},
"include": ["src"]
}Use module nodenext when Node runs the output, bundler when Vite/esbuild does; mixing them causes the classic ESM import errors.
Handle a caught error safelynarrow-unknown
function message(err: unknown): string {
if (err instanceof Error) return err.message
return String(err)
}
try {
risky()
} catch (err) {
console.error(message(err))
}catch variables are unknown under strict mode; narrow before touching properties instead of casting to any.
Model success or failure with a tagged uniondiscriminated-union
type Result<T> =
| { ok: true; value: T }
| { ok: false; error: string }
function unwrap<T>(r: Result<T>): T {
if (r.ok) return r.value
throw new Error(r.error)
}Checking the shared literal field narrows the whole object; add a never-typed default case in switches to catch missed variants at compile time.
Write a custom type guardtype-guard
function isUser(v: unknown): v is { id: string } {
return (
typeof v === 'object' &&
v !== null &&
'id' in v &&
typeof (v as { id: unknown }).id === 'string'
)
}The compiler trusts your guard blindly; a wrong guard silences the checker, so use a schema validator like zod at real I/O boundaries.
Check a shape without widening the typesatisfies-operator
const config = {
port: 3000,
host: 'localhost',
} satisfies Record<string, string | number>
config.port.toFixed(0) // port is still number, not string | numberUse satisfies instead of a type annotation when you want validation plus the narrowest inferred type.
Derive types instead of duplicating themutility-types
type User = { id: string; name: string; email: string }
type UserPatch = Partial<Omit<User, 'id'>>
type PublicUser = Pick<User, 'id' | 'name'>
type ById = Record<string, User>Derived types update automatically when the source type changes; hand-copied interfaces drift.
Constrain a generic to keys of a typegeneric-constraint
function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
return items.map((item) => item[key])
}
const names = pluck([{ id: 1, name: 'a' }], 'name') // string[]K extends keyof T rejects invalid keys at the call site and infers the exact return type.
Add types for an untyped dependencydeclare-untyped-module
// src/types/legacy-lib.d.ts
declare module 'legacy-lib' {
export function init(opts?: Record<string, unknown>): void
export default init
}Check for an @types/legacy-lib package on DefinitelyTyped first; only hand-write declarations when none exists.
Run TypeScript without a build steprun-ts-directly
npx tsx src/main.ts
# or with recent Node versions (type stripping):
node src/main.tsNode's built-in stripping only handles erasable syntax (no enums or namespaces), and neither path checks types; keep tsc --noEmit in CI.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| esbuild | npm | You only need TS syntax stripped and bundled fast; it never checks types, so pair it with tsc --noEmit in CI |
| @swc/core | npm | Same idea as esbuild as a Rust-based transpiler, common inside Next.js and Jest setups |
| flow-bin | npm | Almost never for new work; Meta's type checker lost the ecosystem war and mainly matters in legacy Meta-adjacent codebases |