TypeScript NotesRohit’s interview study guide
Chapter 01

Getting Started & the Compiler

What TypeScript is, how it runs, and the tsconfig options that matter in interviews.

11 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#What TypeScript is (and isn't)

Added

TypeScript is JavaScript plus a static type layer. You write ordinary JavaScript and add type annotations, interfaces and type aliases on top. Nearly every valid JavaScript file is also syntactically valid TypeScript — the compiler may complain about types in it, but it understands it. The type layer exists only for tools: the compiler, your editor, your linter.

The compiler (tsc) does two independent jobs. It checks: reads your code, works out the type of every expression, and reports anything that doesn't fit. And it emits: strips the type syntax out and writes plain JavaScript, optionally rewriting newer syntax for older runtimes. These two jobs barely talk to each other. By default tsc writes the JavaScript even when the check found errors, and tools like esbuild, Vite, tsx and Node itself do only the second job.

A good mental model is scaffolding around a building. The types help you build the thing correctly, but they are taken down before the building opens. At runtime there is no interface, no type, no annotation — just the JavaScript that was underneath. That one fact explains most of TypeScript's "surprises": you can't check a type at runtime, a wrong JSON.parse result isn't caught, and two types with the same shape are interchangeable.

TypeScript
export interface User {
  id: number;
  name: string;
}

export type Role = "admin" | "viewer";

export function greet(user: User, role: Role): string {
  return `Hello ${user.name} (${role})`;
}

const rohit: User = { id: 1, name: "Rohit" };
console.log(greet(rohit, "admin")); // Hello Rohit (admin)
AdvancedEmitted JavaScript for greet
JavaScript
// What tsc emits (user.js)
export function greet(user, role) {
    return `Hello ${user.name} (${role})`;
}
const rohit = { id: 1, name: "Rohit" };
console.log(greet(rohit, "admin"));
What’s happening
  1. The checker reads greet and records its contract: the first argument must have a numeric id and a string name, the second must be exactly "admin" or "viewer", and the result is a string.
  2. It checks rohit against User (both properties present, right types) and then the call greet(rohit, "admin") against the contract. Everything fits, so there are no errors. Passing "owner" would have been TS2345: "Argument of type '\"owner\"' is not assignable to parameter of type 'Role'."
  3. Then the emitter runs. export interface User and export type Role vanish completely — there is nothing to emit for them. The annotations : User, : Role and : string are deleted.
  4. What's left is the JavaScript you'd have written by hand. Note the output is ES module syntax with modern template literals, because nothing told tsc to rewrite them (see target vs module).
  5. This is type erasure: TypeScript adds zero runtime code for types and zero runtime cost. The flip side is that nothing type-related survives to protect you at runtime.
AdvancedNo runtime types

Because types are erased, TypeScript can only check what it can see in the source. Data that arrives at runtime — an API response, JSON.parse, localStorage, a form field — is whatever it is, regardless of the type you gave it.

TypeScript
interface User {
  id: number;
  name: string;
}

const user: User = JSON.parse('{"id":"42"}'); // compiles: JSON.parse returns any
console.log(user.id, typeof user.id); // 42 string — the type said number
console.log(user.name.toUpperCase()); // compiles, then TypeError at runtime

function isUser(value: unknown) {
  return value instanceof User; // ❌ 'User' only refers to a type, but is being used as a value here.
}
What’s happening
  1. JSON.parse is declared to return any, and any is assignable to everything. So annotating the result as User is accepted with no check at all — you've made a claim, not a verification.
  2. At runtime the object is { id: "42" }. user.id is the string "42" (the type said number) and user.name is undefined.
  3. user.name.toUpperCase() type-checks fine (the checker believes name is a string) and crashes with "TypeError: Cannot read properties of undefined (reading 'toUpperCase')".
  4. You can't fix this with instanceof User: User is an interface, it doesn't exist at runtime, so there is nothing to compare against. The compiler says so — TS2693: "'User' only refers to a type, but is being used as a value here." instanceof only works with classes, which are runtime values.
  5. The real fix is to treat outside data as unknown and validate it with code that runs (hand-written checks or a schema library) — see unknown and Validating runtime data.
AdvancedStructural vs nominal typing

Structural typing

Languages like Java and C# are nominal: a Vector is a Point only if it's declared to be (implements Point). TypeScript is structural: a value fits a type if it has the right shape — the required properties with compatible types. Names and declarations don't matter. This is a natural fit for JavaScript, where objects are usually created from literals rather than classes.

TypeScript
interface Point {
  x: number;
  y: number;
}

class Vector {
  x: number;
  y: number;
  constructor(x: number, y: number) {
    this.x = x;
    this.y = y;
  }
}

function distanceFromOrigin(p: Point): number {
  return Math.hypot(p.x, p.y);
}

distanceFromOrigin(new Vector(3, 4)); // 5 — Vector never mentions Point

const reading = { x: 6, y: 8, label: "sensor" };
distanceFromOrigin(reading); // 10 — extra properties are fine

distanceFromOrigin({ x: 1, y: 1, label: "inline" }); // ❌ Object literal may only specify known properties, and 'label' does not exist in type 'Point'.
What’s happening
  1. distanceFromOrigin asks for a Point: anything with a numeric x and a numeric y.
  2. new Vector(3, 4) is accepted even though Vector has no implements Point. The checker compares shapes: Vector has x: number and y: number, so it is a Point as far as TypeScript cares. Result: Math.hypot(3, 4) → 5.
  3. reading has an extra label. That's allowed — a type describes the minimum an object must have, not everything it may have. Math.hypot(6, 8) → 10.
  4. The last call passes the same kind of object, but as a fresh object literal written right at the call. Here TypeScript applies an extra rule, excess property checking: an unknown property in a literal is almost always a typo, so it reports TS2353. Same shape, different verdict — because of where the object came from (see Excess property checking).
  5. The takeaway: compatibility is decided by structure. Two unrelated types with the same members are interchangeable, which is convenient but also means type UserId = string and type OrderId = string are the same type.
AdvancedWhat TypeScript isn't
  • Not a runtime. Nothing executes TypeScript. Node, Deno, Bun and browsers run JavaScript — some of them strip the types for you first, but that's still erasure.
  • Not runtime validation. No checks are inserted into the output. A function declared to take a number will happily receive a string from untyped code.
  • Not a polyfill. Setting an old target rewrites syntax, but never adds missing functions like Array.prototype.at (see target vs module).
  • Not (usually) a change in behaviour. Erasing types leaves the program doing exactly what it did. The exceptions are the few features that generate code — enum, namespace with values, constructor parameter properties — which is why modern setups often ban them with erasableSyntaxOnly (see Running TypeScript).

#Running TypeScript: tsc, ts-node and tsx

Engines run JavaScript, so something has to turn .ts into .js before (or as) the code runs. There are three ways to do it, and the important difference between them is whether type-checking happens on the way.

  1. Compile ahead of time with tsc. Check and emit to an output folder, then run the JavaScript. This is the classic production setup for Node services and libraries.
  2. Run through a loader: ts-node or tsx. The tool hooks into Node's module loading and transpiles each .ts file in memory as it's imported. No dist folder. ts-node uses the TypeScript compiler and type-checks by default; tsx uses esbuild and only strips types.
  3. Let Node strip the types itself. Node 22.6 added --experimental-strip-types, and since Node 23.6 (and 22.18) it's on by default: node app.ts just works for code that uses only erasable syntax.

The mental model: running and checking are separate jobs. Options 2 (with tsx) and 3 make running fast precisely because they skip checking. You then run tsc --noEmit separately — in your editor, a pre-commit hook or CI — to get the type errors.

Shell
# 1. Compile, then run the JavaScript
npx tsc                      # check + emit to outDir (e.g. dist/)
node dist/main.js

# Just check, emit nothing — the usual CI step with any of the options below
npx tsc --noEmit

# 2a. ts-node: compile in memory with the TypeScript API (type-checks unless --transpileOnly)
npx ts-node src/main.ts

# 2b. tsx: strip types with esbuild, no type-checking, fast; has a watch mode
npx tsx src/main.ts
npx tsx watch src/main.ts

# 3. Node's built-in type stripping (Node 23.6+ / 22.18+, no flag)
node src/main.ts
What’s happening
  1. npx tsc reads tsconfig.json, type-checks the whole program, and writes .js files to outDir. node dist/main.js then runs plain JavaScript — no TypeScript tooling is needed in production.
  2. tsc --noEmit does only the checking half. Every other option relies on it, because none of them reports type errors while running (ts-node does by default, but most people turn that off for speed).
  3. ts-node registers a hook so that require/import of a .ts file compiles it with the real TypeScript compiler first. Full fidelity (enums, decorators, everything), but slow on big projects when type-checking, and its ESM support has always been awkward.
  4. tsx does the same with esbuild, which throws types away without checking them. Startup is near-instant, ESM and CommonJS both just work, it reads paths from your tsconfig, and it transforms enums and parameter properties too.
  5. node src/main.ts needs nothing installed at all. Node replaces the type annotations with whitespace and runs the result. It's the simplest option, with the restrictions shown below.
AdvancedWhat Node's type stripping actually does

Node doesn't compile TypeScript — it blanks out the type syntax, character for character, so line and column numbers stay identical and no source map is needed. You can see it with the (experimental) stripTypeScriptTypes helper from node:module: const a: number = 1 becomes const a = 1. Two consequences follow: no type-checking happens, and anything that needs new JavaScript to be generated can't work.

TypeScript
const port: number = "8080"; // ❌ Type 'string' is not assignable to type 'number'.
console.log(port + 1);
What’s happening
  1. tsc rejects the first line with TS2322 — a string isn't a number.
  2. node port.ts doesn't care. It erases : number, leaving const port = "8080";, and runs it.
  3. port + 1 is then string concatenation: it prints 80801, not 8081.
  4. This is the trade-off of every strip-only runner (Node, tsx, esbuild, Vite): fast startup, zero safety. The type error is only reported if something runs the checker.

Syntax that generates code can't be blanked out, so Node refuses it:

TypeScript
enum Level {
  Info,
  Warn,
}

class Logger {
  constructor(private prefix: string) {}

  log(level: Level, msg: string) {
    console.log(`${this.prefix} [${Level[level]}] ${msg}`);
  }
}

new Logger("app").log(Level.Warn, "disk almost full");
text
$ node logger.ts
SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only mode
What’s happening
  1. enum Level { Info, Warn } isn't just a type — tsc turns it into a runtime object (Level.Warn === 1 and Level[1] === "Warn"). Removing it would delete a value the code uses, so Node stops with ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX (see Numeric enums for what tsc generates).
  2. Fix the enum and Node hits the next one: constructor(private prefix: string) is a parameter property, shorthand for declaring a field and assigning this.prefix = prefix. That assignment is generated code, so it fails with "TypeScript parameter property is not supported in strip-only mode" (see Parameter properties and readonly).
  3. The same goes for namespace blocks that contain values and for import x = require(). Pure type syntax — annotations, interface, type, as, generics, the private keyword on a normal field — is fine.
  4. Node 22.7 added an --experimental-transform-types flag that did transform these. Node 26 no longer has it (v26.7 rejects it as a "bad option"), so for Node the answer is now: write erasable TypeScript, or use tsx / tsc.

The erasable version uses a plain object for the "enum" and an explicit field:

TypeScript
const Level = { Info: 0, Warn: 1 } as const;
type Level = (typeof Level)[keyof typeof Level]; // 0 | 1

const levelNames = ["Info", "Warn"] as const;

class Logger {
  private prefix: string; // `private` is just erased — fine

  constructor(prefix: string) {
    this.prefix = prefix;
  }

  log(level: Level, msg: string) {
    console.log(`${this.prefix} [${levelNames[level]}] ${msg}`);
  }
}

new Logger("app").log(Level.Warn, "disk almost full"); // app [Warn] disk almost full
What’s happening
  1. Level is now an ordinary object, which is plain JavaScript, so stripping leaves it intact. as const makes its values the literal types 0 and 1 rather than number.
  2. type Level = (typeof Level)[keyof typeof Level] declares a type with the same name: the union of the object's values, 0 | 1. A value and a type can share a name because they live in different namespaces — the same trick an enum uses internally (see Enums vs literal unions).
  3. The field is declared as private prefix: string; and assigned in the constructor. Node erases private and : string and keeps this.prefix = prefix, so no code needs generating.
  4. node logger.ts now prints app [Warn] disk almost full. To make tsc enforce this style, turn on erasableSyntaxOnly (TS 5.8+): every enum or parameter property becomes TS1294 "This syntax is not allowed when 'erasableSyntaxOnly' is enabled."

Other things to know about Node's stripping: it ignores tsconfig.json (so paths and target do nothing), relative imports need the real .ts extension, .ts files follow the nearest package.json "type" just like .js (with .mts/.cts forcing ESM/CJS), and it refuses to strip files inside node_modules — packages must still ship JavaScript.

Type-checks?SpeedEnums / parameter propertiesReads tsconfig paths?Good for
tsc then nodeYesSlowest (full check)YesChecks with them, doesn't rewrite themProduction builds, libraries
ts-nodeYes, by defaultSlow with checkingYesOnly with an extra pluginOlder projects already using it
tsxNoVery fast (esbuild)YesYesDev scripts, watch mode, tests
node file.tsNoFast, nothing to installNo — erasable syntax onlyNoScripts and services written as erasable TS

#A practical tsconfig.json

tsconfig.json marks the root of a TypeScript project and tells tsc (and your editor) how to treat it. There are dozens of options, but they answer only four questions:

  1. Which files? include, exclude, files.
  2. What environment does the code run in? target, lib, types — which syntax and which globals (window, process) exist.
  3. What should come out, and in what module format? module, outDir, rootDir, sourceMap, declaration — or noEmit if another tool produces the JavaScript.
  4. How strict should checking be? strict plus a few extra flags.

The defaults moved a lot recently: TypeScript 6.0 changed most of them and deprecated old options, and TypeScript 7 removed those options. In TypeScript 7, strict is on by default, target defaults to es2025 (and es5 is gone), types defaults to [] (no @types packages are loaded unless you list them), rootDir defaults to the folder containing tsconfig.json, and baseUrl and the old node/classic module resolution are gone. Being explicit about the important options protects you from these shifts. npx tsc --init writes a sensible starter, and npx tsc --showConfig prints the final config after extends and defaults are applied.

A Node service or library compiled with tsc

JSON
{
  "compilerOptions": {
    // Output: what JavaScript comes out, and where
    "target": "es2024",
    "module": "nodenext",
    "rootDir": "src",
    "outDir": "dist",
    "sourceMap": true,

    // Environment: which globals exist
    "lib": ["es2024"],
    "types": ["node"],

    // Checking
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitOverride": true,
    "noFallthroughCasesInSwitch": true,

    // Keep each file transpilable on its own
    "verbatimModuleSyntax": true,
    "isolatedModules": true,

    // Speed
    "skipLibCheck": true
  },
  "include": ["src"]
}
What’s happening
  1. target: "es2024" says the runtime understands ES2024 syntax (Node 22+ does), so nothing gets rewritten. module: "nodenext" makes tsc follow Node's real rules for ESM vs CommonJS and for resolving imports; it also implies moduleResolution: "nodenext" (see Module systems, .mts and .cts).
  2. rootDir: "src" + outDir: "dist" map src/server.ts to dist/server.js. Without rootDir, TS 6+ assumes the tsconfig folder and errors with TS5011 ("The common source directory of 'tsconfig.json' is './src'. The 'rootDir' setting must be explicitly set…") rather than silently emitting dist/src/server.js.
  3. lib: ["es2024"] — no "dom" — means document and window are unknown, which is right for a server. types: ["node"] loads @types/node for process, Buffer and node: modules; with the TS 6+ default of [], using process fails with TS2591 "Cannot find name 'process'. Do you need to install type definitions for node?"
  4. strict turns on the core safety family (next topic). noUncheckedIndexedAccess makes arr[0] and record[key] include undefined; exactOptionalPropertyTypes stops undefined sneaking into optional properties; noImplicitOverride requires the override keyword; noFallthroughCasesInSwitch catches a forgotten break.
  5. verbatimModuleSyntax forces import type for types, so imports are emitted exactly as written and per-file tools never guess wrong. isolatedModules flags anything a single-file transpiler can't handle. skipLibCheck skips re-checking every .d.ts in node_modules — the biggest cheap speed win.
Advancedtsconfig for a bundler-built front-end app

A front-end app built by a bundler

JSON
{
  "compilerOptions": {
    "target": "es2022",
    "module": "preserve",
    "lib": ["es2022", "dom", "dom.iterable"],
    "jsx": "react-jsx",
    "noEmit": true,

    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noFallthroughCasesInSwitch": true,

    "verbatimModuleSyntax": true,
    "isolatedModules": true,
    "allowImportingTsExtensions": true,
    "skipLibCheck": true,

    "paths": { "@/*": ["./src/*"] }
  },
  "include": ["src"]
}
What’s happening
  1. noEmit: true is the key line: Vite, webpack or esbuild produce the JavaScript, and tsc is only a type-checker here (tsc --noEmit in CI, the editor the rest of the time).
  2. module: "preserve" (TS 5.4+) means "leave my imports exactly as written — a bundler will deal with them". It implies moduleResolution: "bundler", which allows extensionless imports like ./Button and understands package.json exports.
  3. lib includes "dom" and "dom.iterable" because this code runs in a browser. jsx: "react-jsx" type-checks JSX against React's automatic runtime.
  4. allowImportingTsExtensions permits import "./Button.tsx"; tsc only accepts it together with noEmit, emitDeclarationOnly or rewriteRelativeImportExtensions (TS5096 otherwise), because an emitted .js file importing ./Button.tsx would break at runtime.
  5. paths gives the @/ alias. It only teaches the checker where @/components/Button lives; the bundler needs the same alias in its own config (see Path aliases).

Here is what one of those extra checking flags buys you. The examples on this site are checked without it, so the comments show what changes when it's on:

TypeScript
const names = ["Ada", "Linus"];
const third = names[2]; // default: string — with noUncheckedIndexedAccess: string | undefined

const scores: Record<string, number> = { ada: 10 };
const linus = scores["linus"]; // default: number — with the flag: number | undefined

// With the flag, these lines become errors until you handle undefined:
console.log(third.toUpperCase()); // TS18048: 'third' is possibly 'undefined'.
console.log(linus.toFixed(1)); // TS18048: 'linus' is possibly 'undefined'.
What’s happening
  1. names is string[]. An array type says nothing about its length, so by default names[2] is typed string even though at runtime it's undefined.
  2. Record<string, number> claims every string key maps to a number, so scores["linus"] is typed number — also undefined at runtime.
  3. Both console.log lines compile by default and crash at runtime: "Cannot read properties of undefined".
  4. With noUncheckedIndexedAccess, index access through an index signature or array index adds | undefined, so both lines become TS18048 until you check (if (third)) or use ?.. It isn't part of strict because it's noisy in loops — for (let i = 0; i < arr.length; i++) arr[i] is also T | undefined — but it catches a real class of bugs (see Index signatures).

#strict and strictNullChecks

strict: true is an umbrella flag: it turns on a family of checks that make the type system honest. You can still switch individual members off ("strict": true, "strictPropertyInitialization": false), and any new strict check added in a future version is enabled automatically. Since TypeScript 6.0 strict is true by default; before that a tsconfig without it got the loose, legacy behaviour.

The most important member is strictNullChecks. Without it, null and undefined are silently part of every type: a string might be null and the checker won't mention it. With it, they're separate types that only appear where you write them (string | null), and the checker forces you to handle them before use. Accessing a property of null or undefined is the most common runtime error in JavaScript, so this one flag catches more bugs than any other.

The mental model: without strictNullChecks, every type has an invisible | null | undefined that the checker ignores. Turning it on makes that part visible — and then insists you deal with it.

TypeScript
let user: string | null = null;
console.log(user.toUpperCase()); // ❌ 'user' is possibly 'null'.

// The fixes, on a value that really could be either:
function shout(name: string | null): string {
  if (name !== null) {
    return name.toUpperCase(); // narrowed to string
  }
  return "ANONYMOUS";
}

function shoutMaybe(name: string | null) {
  return name?.toUpperCase(); // string | undefined — no crash
}
What’s happening
  1. user is declared as string | null — "a string, or deliberately nothing" — and starts as null.
  2. user.toUpperCase() would throw at runtime ("Cannot read properties of null"). With strictNullChecks the checker sees that null has no toUpperCase and reports TS18047: "'user' is possibly 'null'." (It actually knows more: right after = null, control-flow analysis has narrowed user to exactly null.)
  3. In shout, name really could be either. Inside if (name !== null) the checker narrows name to string, so the call is allowed. This is control-flow analysis: the type of a variable changes depending on the checks that guard the line (see Narrowing with typeof).
  4. name?.toUpperCase() is the other common fix: optional chaining short-circuits to undefined when name is null, so the expression's type is string | undefined and nothing can crash — the caller now has to deal with undefined instead.
AdvancedCorrecting my notes: the possibly-null error wording
AdvancedWhat happens with strictNullChecks off

My notes also said that with strictNullChecks: false the code "would have been allowed". It would — and that's exactly the problem:

TypeScript
let user: string | null = null;
console.log(user.toUpperCase()); // ❌ 'user' is possibly 'null'.

function findUser(id: number): { name: string } | undefined {
  return id === 1 ? { name: "Rohit" } : undefined;
}
const found = findUser(2);
console.log(found.name); // ❌ 'found' is possibly 'undefined'.

let count: number = null; // ❌ Type 'null' is not assignable to type 'number'.
What’s happening
  1. With strict on (as here), all three marked lines are errors: TS18047, TS18048 and TS2322.
  2. Set "strictNullChecks": false (or "strict": false) and the file compiles with zero errors. string | null collapses to just string, { name: string } | undefined to { name: string }, and null is assignable to number.
  3. At runtime the first console.log immediately throws "TypeError: Cannot read properties of null (reading 'toUpperCase')". findUser(2) returns undefined, so found.name would throw too.
  4. Without the flag, writing | null in a type is documentation only — the checker can't use it. That's why no new project should turn it off, and why migrating an old codebase to strictNullChecks usually finds real bugs.
AdvancedThe rest of the strict family
TypeScript
function double(n) { // ❌ Parameter 'n' implicitly has an 'any' type.
  return n * 2;
}

class Account {
  owner: string; // ❌ Property 'owner' has no initializer and is not definitely assigned in the constructor.
  balance = 0;
}

try {
  JSON.parse("{bad json");
} catch (err) {
  console.log(err.message); // ❌ 'err' is of type 'unknown'.
}

function greet(name: string) {
  return `Hi ${name}`;
}
greet.call(undefined, 42); // ❌ Argument of type 'number' is not assignable to parameter of type 'string'.

type Handler = (value: string | number) => void;
const onlyStrings = (value: string) => console.log(value.toUpperCase());
const handler: Handler = onlyStrings; // ❌ Type '(value: string) => void' is not assignable to type 'Handler'.

function shout() {
  return this.name.toUpperCase(); // ❌ 'this' implicitly has type 'any' because it does not have a type annotation.
}
What’s happening
  1. noImplicitAny: n has no annotation and nothing to infer it from, so it would silently be any (no checking at all). Strict mode reports TS7006 instead. Callbacks are exempt when the context supplies the type, like arr.map(n => n * 2).
  2. strictPropertyInitialization: owner is declared as string but never assigned, so after new Account() it's actually undefined. TS2564 makes you initialise it, assign it in the constructor, make it optional (owner?: string), or assert it with owner!: string.
  3. useUnknownInCatchVariables: anything can be thrown in JavaScript (throw "oops"), so err is unknown rather than any. You must narrow before reading .message — if (err instanceof Error) (see unknown in catch clauses). Error TS18046.
  4. strictBindCallApply: call, apply and bind are checked against the real signature, so greet.call(undefined, 42) is TS2345. Without the flag they accept any arguments.
  5. strictFunctionTypes: a Handler may be called with a number, but onlyStrings would then run (42).toUpperCase(). Parameters are checked contravariantly: a function that accepts less can't stand in for one that must accept more (TS2322). Method-shorthand signatures are exempt — see Describing functions with types and interfaces.
  6. noImplicitThis: in a standalone function, this depends on how it's called, so its type is unknown. TS2683 asks you to declare it with a this parameter (see Typing this).
Flag (all on under strict)What it catches
strictNullChecksUsing a value that may be null/undefined
noImplicitAnyParameters and variables that silently become any
strictPropertyInitializationClass fields never assigned in the constructor
strictFunctionTypesUnsafe function-type assignments (parameter contravariance)
strictBindCallApplyWrong arguments to call/apply/bind
noImplicitThisthis with an unknown type
useUnknownInCatchVariablescatch (err) treated as any
strictBuiltinIteratorReturn (TS 5.6+)Built-in iterators' return value typed any instead of undefined
alwaysStrictEmits "use strict"; in TS 7 it can't be turned off

#exactOptionalPropertyTypes

Advanced

An optional property like nickname?: string normally means two different things at once: the key may be missing, or the key may be present with the value undefined. By default TypeScript treats the two as the same and types the property as string | undefined.

JavaScript doesn't treat them as the same. "nickname" in obj, Object.keys, Object.entries, hasOwnProperty and — most importantly — object spread all tell a missing key apart from a key holding undefined. exactOptionalPropertyTypes makes the type system match: ? means only "may be missing". If you also want to allow an explicit undefined, you write it: nickname?: string | undefined.

The mental model: there are three states for a property — has a value, holds undefined, or doesn't exist. Without the flag, ? lumps the last two together; with it, ? means "doesn't exist" and | undefined means "holds undefined".

TypeScript
type Profile = { nickname?: string };

const missing: Profile = {};
const explicit: Profile = { nickname: undefined }; // allowed by default

console.log("nickname" in missing, "nickname" in explicit); // false true
console.log(Object.keys(missing), Object.keys(explicit)); // [] [ 'nickname' ]
console.log(JSON.stringify(missing), JSON.stringify(explicit)); // {} {}
console.log({ nickname: "Ro", ...missing }); // { nickname: 'Ro' }
console.log({ nickname: "Ro", ...explicit }); // { nickname: undefined }
What’s happening
  1. Both objects are valid Profiles by default, and reading .nickname gives undefined for both — that's why TypeScript traditionally treated them alike.
  2. in and Object.keys disagree: explicit really has a nickname key, missing doesn't. Code that iterates keys (validation, form serialisation, Object.entries) sees one extra entry.
  3. JSON.stringify drops undefined values, so both serialise to {} — one of the few places where the difference disappears.
  4. Spread is where it bites. Spreading missing copies nothing, so the default "Ro" survives. Spreading explicit copies the key with its undefined value, overwriting "Ro".

Here is that spread difference turning into a real bug — and the type system lying about it:

TypeScript
interface Options {
  retries?: number;
  timeout?: number;
}

const defaults = { retries: 3, timeout: 1000 };

function withDefaults(overrides: Options) {
  return { ...defaults, ...overrides };
}

const opts = withDefaults({ retries: undefined }); // with the flag: TS2379 on this argument
const retries: number = opts.retries; // the type says number…
console.log(retries); // …but it's undefined
What’s happening
  1. withDefaults spreads the caller's overrides over the defaults. The return type is computed from the spread: retries comes from defaults (number) unless overrides has it, and an optional property is assumed to be either absent or a number. So the checker types opts.retries as plain number.
  2. The caller passes { retries: undefined }. By default that's a valid Options, because retries?: number quietly allows undefined.
  3. At runtime the spread copies retries: undefined over 3. opts.retries is undefined, and retries.toFixed() would throw — with no compile error anywhere.
  4. With exactOptionalPropertyTypes, the call itself is rejected: TS2379 "Argument of type '{ retries: undefined; }' is not assignable to parameter of type 'Options' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties." The flag closes the gap between what the spread type assumes and what can actually be passed.

Turning the flag on changes what you're allowed to write:

TypeScript
interface Settings {
  theme?: "light" | "dark"; // may be missing, but never holds undefined
  fontSize?: number | undefined; // may be missing OR hold undefined
}

const a: Settings = { theme: "dark" };
const b: Settings = { fontSize: undefined }; // fine either way: undefined is written out
const c: Settings = { theme: undefined }; // with the flag: TS2375

function setTheme(s: Settings, theme: "light" | "dark" | undefined) {
  s.theme = theme; // with the flag: TS2412
}

// Flag-friendly ways to "unset" a property:
function setThemeExact(s: Settings, theme: "light" | "dark" | undefined) {
  if (theme === undefined) delete s.theme;
  else s.theme = theme;
}
const d: Settings = { ...(Math.random() > 0.5 ? { theme: "dark" } : {}) };
console.log(a, b, c, d, setTheme, setThemeExact);
What’s happening
  1. theme?: "light" | "dark" under the flag means "absent, or one of those two strings". { theme: undefined } is now TS2375: "Type '{ theme: undefined; }' is not assignable to type 'Settings' with 'exactOptionalPropertyTypes: true'."
  2. fontSize?: number | undefined opts back into the old behaviour for that one property, so b compiles with or without the flag. This is the standard migration fix when undefined really is a meaningful value.
  3. s.theme = theme fails (TS2412) because theme might be undefined, and assigning it would create the "present but undefined" state the type forbids.
  4. The fixes are to remove the key instead (delete s.theme) or to build the object with a conditional spread so the key is only added when there's a value. Reading s.theme still gives "light" | "dark" | undefined — a missing key still reads as undefined.

When to use it: it's worth it in code that merges objects (configs, options, PATCH payloads where "absent" means "don't change" but null/undefined means "clear"). The cost is friction: you'll write | undefined in many interfaces, Partial<T> no longer accepts explicit undefined, and some third-party typings weren't written with it in mind. It is not part of strict, but TypeScript 7's tsc --init turns it on.

#target vs module

These two options look related but control completely different things, which is why they're easy to mix up.

  • target is the ECMAScript version of the syntax in the output. Any syntax newer than the target gets rewritten into older equivalents (class fields into constructor assignments, ?. and ?? into ternaries). It also picks the default lib — which built-in APIs the checker believes exist.
  • module is how import/export are written in the output: kept as ES modules, turned into CommonJS require/exports, or left exactly as written for a bundler (preserve). The Node values (node16, node20, nodenext) also decide per file which format to use and which resolution rules apply.

The mental model: target answers "which language version does the engine speak?" and module answers "how do files load each other?". They're independent: ES2022 syntax in CommonJS files is perfectly normal (that's most Node code from a few years ago).

TypeScript
import { readFile } from "node:fs/promises";

export class Config {
  path = "./config.json";

  async load() {
    const text = await readFile(this.path, "utf8");
    return JSON.parse(text)?.port ?? 3000;
  }
}
AdvancedEmitted code for different target values

target: "es2019", module: "esnext"

JavaScript
import { readFile } from "node:fs/promises";
export class Config {
    constructor() {
        this.path = "./config.json";
    }
    async load() {
        var _a;
        var _b;
        const text = await readFile(this.path, "utf8");
        return (_b = (_a = JSON.parse(text)) === null || _a === void 0 ? void 0 : _a.port) !== null && _b !== void 0 ? _b : 3000;
    }
}

target: "es2022", module: "esnext"

JavaScript
import { readFile } from "node:fs/promises";
export class Config {
    path = "./config.json";
    async load() {
        const text = await readFile(this.path, "utf8");
        return JSON.parse(text)?.port ?? 3000;
    }
}
What’s happening
  1. Same source, only target differs. Both outputs keep import/export untouched, because module is esnext in both.
  2. Class fields are ES2022. For es2019, path = "./config.json" is moved into a generated constructor as this.path = …. For es2022 it stays a class field.
  3. Optional chaining and ?? are ES2020. For es2019 they become temporaries (_a, _b) and explicit === null || === void 0 checks — void 0 is a safe way to write undefined. For es2022 they're left alone.
  4. async/await (ES2017) is unchanged in both because both targets already support it. With target: "es2015" it would be rewritten into a generator plus an __awaiter helper.
  5. The rule: target never changes behaviour, only spelling. Pick the oldest runtime you must support (es2022 covers every current browser and Node version) and let everything newer be downlevelled.
AdvancedEmitted code for different module values

Now hold target at es2022 and change module:

module: "commonjs"

JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Config = void 0;
const promises_1 = require("node:fs/promises");
class Config {
    path = "./config.json";
    async load() {
        const text = await (0, promises_1.readFile)(this.path, "utf8");
        return JSON.parse(text)?.port ?? 3000;
    }
}
exports.Config = Config;

module: "esnext"

JavaScript
import { readFile } from "node:fs/promises";
export class Config {
    path = "./config.json";
    async load() {
        const text = await readFile(this.path, "utf8");
        return JSON.parse(text)?.port ?? 3000;
    }
}
What’s happening
  1. The class body is identical in both — target is the same, so the syntax inside is the same.
  2. With commonjs, import { readFile } from … becomes const promises_1 = require("node:fs/promises"), and every use becomes (0, promises_1.readFile)(…). The (0, …) calls the function without promises_1 as this, matching how a real imported binding behaves.
  3. export class Config becomes a plain class Config plus exports.Config = Config. exports.Config = void 0 at the top pre-declares the export, and __esModule: true marks the file as converted ESM so that default-import interop works when another file imports it.
  4. "use strict" is added because ES modules are always strict and CommonJS files aren't — TypeScript keeps the semantics the same.
  5. With esnext the module syntax passes through untouched. Which one is right depends on who runs the output: Node needs the format that matches the file's package.json "type" (that's what nodenext works out for you), a bundler wants ESM or preserve.
Advancedtarget doesn't polyfill APIs

target doesn't polyfill

target rewrites syntax only. Missing APIs — Array.prototype.at, Object.groupBy, Promise.withResolvers — are never added. What target does affect is the default lib, so the checker knows which APIs to expect:

TypeScript
const last = [1, 2, 3].at(-1);
// with target es2019 (and no lib): TS2550: Property 'at' does not exist on type 'number[]'.
//   Do you need to change your target library? Try changing the 'lib' compiler option to 'es2022' or later.
// with target es2019 + lib [es2022, dom]: compiles, output still calls .at() — crashes on an ES2019 engine
console.log(last); // 3 on a modern runtime
What’s happening
  1. Array.prototype.at arrived in ES2022. With target: "es2019" and no explicit lib, the default lib is ES2019's, which doesn't declare .at, so the checker reports TS2550 — that's TypeScript protecting you.
  2. Adding "lib": ["es2022", "dom"] silences it (dom or @types/node is still needed for console): you've told the checker "this API will exist at runtime". The output is still [1, 2, 3].at(-1) — .at is a method call, not syntax, so there's nothing to rewrite.
  3. On an engine without .at, that line throws "TypeError: [1,2,3].at is not a function". If you need it there, you load a polyfill (core-js, or your bundler's polyfill setup) yourself.
  4. Summary: target = syntax rewriting; lib = what the checker assumes exists; polyfills = your job.
Predict the output

With "target": "es2022" and "module": "commonjs", what does this compile to?

TypeScript
const a: number | null = null;
export const port = a ?? 8080;
Show answer
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.port = void 0;
const a = null;
exports.port = a ?? 8080;
What’s happening
  1. : number | null is a type annotation, so it's erased: const a = null;.
  2. ?? is ES2020 syntax and the target is ES2022, so it stays exactly as written. Only an older target such as es2019 would rewrite it.
  3. module: "commonjs" turns export const port = … into exports.port = …, pre-declared with exports.port = void 0 at the top, plus the __esModule marker.
  4. "use strict" appears because CommonJS output must be made strict to match ES module semantics. Running it gives exports.port === 8080.

#Module systems, .mts and .cts

Node has two module systems: CommonJS (require/module.exports) and ES modules (import/export). Node decides which one a .js file uses from the nearest package.json: "type": "module" means ESM, no "type" (or "commonjs") means CommonJS. The extensions .mjs and .cjs override that — always ESM and always CommonJS.

TypeScript mirrors this exactly. With module: "nodenext", tsc looks at the same package.json and decides each file's format the same way Node will, then emits that format and applies that format's import rules. The TypeScript extensions map one-to-one:

SourceOutputFormat
.ts / .tsx.jsDecided by package.json "type"
.mts.mjsAlways ESM
.cts.cjsAlways CommonJS
.d.ts / .d.mts / .d.cts—Declarations for .js / .mjs / .cjs

The mental model: under nodenext, TypeScript pretends to be Node for every file. If Node would load a file as ESM, TypeScript type-checks it as ESM — including Node's strict rule that ESM imports need full file extensions.

Take this small project with "module": "nodenext", rootDir: "src" and outDir: "dist":

src/math.ts, src/main.ts, src/esm.mts, src/legacy.cts
// src/math.ts
export function add(a: number, b: number) { return a + b; }

// src/main.ts
import { add } from "./math";
console.log(add(1, 2));

// src/esm.mts
export const esmOnly = "from .mts";

// src/legacy.cts
export const cjsOnly = "from .cts";
AdvancedEmitted CommonJS, .mjs and .cjs output
JavaScript
// package.json has no "type"  →  dist/main.js is CommonJS
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const math_1 = require("./math");
console.log((0, math_1.add)(1, 2));

// dist/esm.mjs — ESM regardless of package.json
export const esmOnly = "from .mts";

// dist/legacy.cjs — CommonJS regardless of package.json
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.cjsOnly = void 0;
exports.cjsOnly = "from .cts";
AdvancedTS2835: missing extensions under type: module
text
// Now add "type": "module" to package.json and run tsc again:
src/main.ts(1,21): error TS2835: Relative import paths need explicit file extensions in ECMAScript
imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './math.js'?
What’s happening
  1. Without a "type" field, Node would treat dist/main.js as CommonJS, so tsc emits require. CommonJS resolution tries extensions for you, so the extensionless "./math" is fine.
  2. .mts and .cts ignore package.json: esm.mts becomes ESM in esm.mjs, legacy.cts becomes CommonJS in legacy.cjs. Use them for the odd file that must be the "other" format.
  3. Add "type": "module" and main.ts is now ESM. Node's ESM loader never guesses extensions — import "./math" would fail at runtime — so TypeScript reports TS2835 at compile time instead.
  4. The fix is import { add } from "./math.js" — .js, even though the file on disk is math.ts. The specifier is a runtime string and tsc doesn't rewrite it, so you write the path that will exist after compilation; during checking TypeScript maps ./math.js back to ./math.ts.
  5. If you'd rather write .ts, TS 5.7+'s rewriteRelativeImportExtensions rewrites relative "./math.ts" to "./math.js" in the output — handy when the same code also runs directly with node src/main.ts.
AdvancedCommonJS importing ESM
src/legacy.cts
import { esmOnly } from "./esm.mjs";
export const cjsOnly = "from .cts " + esmOnly;
What’s happening
  1. legacy.cts is CommonJS, so this import will be emitted as require("./esm.mjs"). Historically require couldn't load ES modules at all.
  2. With module: "node16", TypeScript models that old Node and reports TS1479: "The current file is a CommonJS module whose imports will produce 'require' calls; however, the referenced file is an ECMAScript module and cannot be imported with 'require'. Consider writing a dynamic 'import(\"./esm.mjs\")' call instead."
  3. With module: "nodenext" (or node20) it compiles, because Node 22.12+ supports require() of ES modules. Running it prints 3 from .mts from .cts from .mts.
  4. The catch: require() can't wait, so if esm.mts used top-level await, tsc still compiles but Node throws ERR_REQUIRE_ASYNC_MODULE ("require() cannot be used on an ESM graph with top-level await"). The checker doesn't catch that one.
AdvancedmoduleResolution: from import string to file

moduleResolution: how an import string becomes a file

moduleResolution is the algorithm that turns "./math.js" or "lodash/fp" into a file. In TypeScript 7 there are three choices, and you rarely set it yourself because module implies it:

  • nodenext / node16 — exactly what Node does: ESM files need extensions, CommonJS files don't, package.json exports and imports are honoured with the right conditions (import vs require). Required when module is nodenext (TS5110 if you mix them). Use it for anything Node runs directly and for libraries, because code that satisfies Node's rules works in bundlers too.
  • bundler — what Vite, webpack and esbuild do: extensionless and directory imports are allowed, exports is honoured. Use it with module: "esnext" or "preserve" for apps that go through a bundler.
  • The old node (a.k.a. node10) and classic modes were deprecated in TypeScript 6.0 and are gone in 7. They didn't understand exports at all.

#Path aliases: baseUrl and paths

Deep relative imports like ../../../../utils/format are hard to read and break when files move. Path aliases let you write @/utils/format instead. Historically you set baseUrl (a folder that bare imports are resolved from) and optionally paths (pattern → folder mappings).

That was the old way. TypeScript 6.0 deprecated baseUrl and TypeScript 7 removed it: the modern form is paths on its own, with targets written relative to the tsconfig file. And the direction the ecosystem is heading is to not put aliases in tsconfig at all, but in package.json "imports", which Node, bundlers and TypeScript all understand.

The single most important thing to know: paths only affects type-checking. tsc does not rewrite @/utils/format in the output. Whatever runs or bundles the code must be taught the same alias separately.

JSON
{
  "compilerOptions": {
    "baseUrl": "./src"
  }
}
AdvancedTS5102: baseUrl removed in TypeScript 7
text
$ npx tsc     # TypeScript 7
tsconfig.json(1,35): error TS5102: Option 'baseUrl' has been removed. Please remove it from your configuration.
  Use '"paths": {"*": ["./src/*"]}' instead.
What’s happening
  1. With baseUrl: "./src", older compilers resolved a bare import like import { x } from "utils/x" as ./src/utils/x.ts. Every folder in src effectively became a fake package name.
  2. That was confusing (is utils an npm package or a folder?) and nothing at runtime did the same thing, so it was deprecated in 6.0.
  3. TypeScript 7 refuses the option outright with TS5102, and even suggests the replacement: "paths": {"*": ["./src/*"]} reproduces the old behaviour. Better still is a distinct prefix like @/, so aliases can't be confused with packages.
Advancedtsconfig with paths and no baseUrl
JSON
{
  "compilerOptions": {
    "rootDir": "src",
    "outDir": "dist",
    "paths": { "@/*": ["./src/*"] }
  }
}
AdvancedImporting through the @/ alias
src/main.ts
import { x } from "@/utils/x";
console.log(x);
AdvancedRuntime failure: paths isn't rewritten
text
$ npx tsc && cat dist/main.js
import { x } from "@/utils/x";
console.log(x);

$ node dist/main.js
Error [ERR_MODULE_NOT_FOUND]: Cannot find package '@/utils' imported from .../dist/main.js
What’s happening
  1. "@/*": ["./src/*"] says: when an import starts with @/, take the rest (utils/x) and look for it under ./src/. Without baseUrl, paths targets are resolved relative to the tsconfig, so the leading ./ matters.
  2. The checker finds src/utils/x.ts, types x, and reports no errors. Editors use the same mapping for go-to-definition and auto-imports.
  3. The emitted dist/main.js still says "@/utils/x". tsc never rewrites module specifiers based on paths.
  4. Node sees a bare specifier, looks for an npm package called @/utils, and fails with ERR_MODULE_NOT_FOUND. So paths only works when something else resolves the alias too: Vite/webpack alias config (or a tsconfig-paths plugin), tsx (reads paths itself), Jest's moduleNameMapper — or a post-build rewrite tool.
AdvancedThe modern direction: subpath imports

Node has had a built-in alias feature for years: the "imports" field in package.json. Keys must start with #, and because it's a Node feature, the alias works at runtime with no extra tooling. TypeScript reads it under moduleResolution nodenext or bundler.

JSON
{
  "type": "module",
  "imports": {
    "#src/*": "./dist/*"
  }
}
src/main.ts
import { greet } from "#src/utils/greet.js";
console.log(greet("Rohit")); // hi Rohit
What’s happening
  1. "#src/*": "./dist/*" points the alias at the compiled output, because that's what Node will load at runtime: #src/utils/greet.js → ./dist/utils/greet.js.
  2. During type-checking dist may not even exist yet. TypeScript notices the target is inside outDir and maps it back to the source file src/utils/greet.ts (using rootDir), so the import is fully typed.
  3. tsc leaves the specifier as is — and this time that's correct, because Node resolves #src/... itself. node dist/main.js prints hi Rohit with no loader, plugin or rewrite step.
  4. If you run the .ts files directly with Node's type stripping, map to the sources instead — "#src/*": "./src/*" — and import "#src/utils/greet.ts" (with allowImportingTsExtensions and noEmit in tsconfig). Bundlers support imports too, so one alias definition serves every tool.

#Project references and composite

In a large repo — a monorepo, or an app with a shared library and separate test projects — compiling everything as one TypeScript program means every change re-checks everything, and nothing stops the UI code from importing server internals. Project references split the code into several smaller TypeScript projects (each with its own tsconfig) with explicit dependencies between them.

A project that others reference must set composite: true. That flag guarantees what the build system needs: it turns on declaration (so dependents read the project's .d.ts output instead of re-checking its source), turns on incremental (so a .tsbuildinfo file records what's up to date), and requires every file to be matched by include/files. Then tsc -b (build mode) walks the dependency graph, builds projects in order, and skips any whose inputs haven't changed.

If you know Gradle, the mental model is modules with declared dependencies: app depends on shared, building app builds shared first, and app only sees shared's public API (its .d.ts), not its internals.

JSON
// packages/shared/tsconfig.json
{
  "compilerOptions": {
    "composite": true,
    "rootDir": "src",
    "outDir": "dist"
  }
}

// packages/app/tsconfig.json
{
  "compilerOptions": {
    "rootDir": "src",
    "outDir": "dist"
  },
  "references": [{ "path": "../shared" }]
}

// tsconfig.json (repo root): a "solution" file that only lists projects
{
  "files": [],
  "references": [{ "path": "packages/shared" }, { "path": "packages/app" }]
}
Advancedtsc -b --verbose build output
text
$ npx tsc -b --verbose
Projects in this build:
    * packages/shared/tsconfig.json
    * packages/app/tsconfig.json
    * tsconfig.json
Project 'packages/shared/tsconfig.json' is out of date because output file 'packages/shared/tsconfig.tsbuildinfo' does not exist
Building project 'packages/shared/tsconfig.json'...
Project 'packages/app/tsconfig.json' is out of date because output file 'packages/app/tsconfig.tsbuildinfo' does not exist
Building project 'packages/app/tsconfig.json'...

$ npx tsc -b --verbose     # again, nothing changed
Project 'packages/shared/tsconfig.json' is up to date because newest input 'packages/shared/src/index.ts' is older than output 'packages/shared/tsconfig.tsbuildinfo'
Project 'packages/app/tsconfig.json' is up to date because newest input 'packages/app/src/main.ts' is older than output 'packages/app/dist/main.js'
What’s happening
  1. shared is composite, so building it emits dist/index.js, dist/index.d.ts (export declare function formatPrice(cents: number): string;) and tsconfig.tsbuildinfo.
  2. app lists shared in references. When app imports from shared's source, TypeScript substitutes shared's .d.ts output — so app is checked against the declared API without re-checking shared's implementation.
  3. The root tsconfig has "files": [] so it compiles nothing itself; it's just an entry point that lets tsc -b find every project. tsc -b sorts the graph (shared before app) and builds each in turn.
  4. On the second run, tsc -b compares timestamps against the .tsbuildinfo files and skips both projects. Change a file in app and only app rebuilds; change shared's public API and app is rebuilt too.
  5. The errors you'll meet: leave out composite and you get TS6306 "Referenced project '…/packages/shared' must have setting \"composite\": true." Run plain tsc -p packages/app before shared is built and you get TS6305 "Output file '…/shared/dist/index.d.ts' has not been built from source file '…/shared/src/index.ts'." — use tsc -b, which builds dependencies first.
AdvancedUseful tsc -b build-mode flags

Useful build-mode flags: tsc -b --watch, --clean (delete outputs), --force (rebuild everything), --dry (show what would build). Trade-offs: references add configuration and require declaration output, so they pay off in big repos and monorepos, not in a single small app. They also give you enforced boundaries — a project can only import what it references.

#Type-checking JavaScript with JSDoc

TypeScript can check plain JavaScript files. Its checker already infers types for JavaScript (that's what powers autocomplete in VS Code for .js files). Turning on checking makes it report errors too, and JSDoc comments let you write the types that inference can't work out — so you get most of TypeScript's safety with no build step, because the file stays valid JavaScript.

There are three switches:

  • allowJs — .js files become part of the program: TypeScript files can import them with types inferred, and tsc can emit them to outDir.
  • checkJs — report type errors in those .js files too (implies allowJs).
  • // @ts-check at the top of one file — check just that file, even without checkJs. // @ts-nocheck opts a file out when checkJs is on.

The mental model: JSDoc is TypeScript's type syntax moved into comments. @param {Item[]} items means the same thing as items: Item[].

JavaScript
// @ts-check

/**
 * @typedef {Object} Item
 * @property {string} name
 * @property {number} price  Price in cents
 * @property {number} [qty]  Optional, defaults to 1
 */

/**
 * @param {Item[]} items
 * @returns {number}
 */
export function total(items) {
  return items.reduce((sum, item) => sum + item.price * (item.qty ?? 1), 0);
}

/** @type {Item} */
const coffee = { name: "Coffee", price: "450" }; // TS2322: Type 'string' is not assignable to type 'number'.

total([coffee]);
total("coffee"); // TS2345: Argument of type 'string' is not assignable to parameter of type 'Item[]'.
What’s happening
  1. // @ts-check turns on error reporting for this file. Without it (and without checkJs), tsc with allowJs reports nothing here — exit code 0.
  2. @typedef declares a named object type Item, the JSDoc equivalent of type Item = { name: string; price: number; qty?: number }. Square brackets in [qty] mark the property optional.
  3. @param {Item[]} items and @returns {number} type the function's signature. Inside it, item.qty is number | undefined, which is why ?? 1 is needed — the same rules as in a .ts file.
  4. /** @type {Item} */ annotates the variable. price: "450" is a string, so TS2322 is reported at that property. total("coffee") breaks the @param contract: TS2345.
  5. The file is still plain JavaScript — Node and browsers run it as is, with the types living only in comments.
AdvancedUsing JavaScript and TypeScript together

With allowJs, a .ts file can import from a .js file and get its JSDoc types, which makes gradual migration straightforward: convert files one at a time, and both kinds keep type-checking against each other.

JavaScript
// @ts-check
/** @import { Item } from "./cart.js" */

/** @type {Item} */
const tea = { name: "Tea", price: 300 };

/** @satisfies {Record<string, number>} */
const prices = { tea: 300, coffee: 450 };
prices.milk; // TS2339: Property 'milk' does not exist on type '{ tea: number; coffee: number; }'.

const input = /** @type {HTMLInputElement} */ (document.querySelector("#qty"));
console.log(tea, input.value);
What’s happening
  1. @import (TS 5.5+) imports a type into a JS file without creating a runtime import — the JSDoc version of import type { Item } from "./cart.js". Before 5.5 you wrote @type {import("./cart.js").Item} inline.
  2. @satisfies (TS 5.0+) checks the object against Record<string, number> but keeps its precise inferred type { tea: number; coffee: number }. That's why prices.milk is an error rather than number — the same behaviour as the satisfies operator in TypeScript.
  3. /** @type {HTMLInputElement} */ (expr) is a type assertion in JSDoc form — the equivalent of as HTMLInputElement. The parentheses are required: they tell TypeScript which expression the comment applies to.
  4. A TypeScript file importing total from ./cart.js sees (items: Item[]) => number, and passing [{ name: "Tea" }] is TS2741 "Property 'price' is missing in type '{ name: string; }' but required in type 'Item'." — the JSDoc types cross the language boundary.

When to choose JSDoc over .ts: libraries or tools that want to ship source with no build step (Svelte's codebase famously moved to JSDoc), config files, scripts, and the early stages of a migration. The downsides: the syntax is verbose, some advanced types are awkward to write in comments (you can always put them in a .d.ts file and @import them), and you need tsc --noEmit or the editor to actually see the errors. tsc can also generate .d.ts files from JSDoc'd JavaScript — see Generating declarations from JSDoc.

#Keeping the type checker fast

On a big codebase, tsc time and editor responsiveness become real problems. Most slowness comes from a few places: re-checking things that haven't changed (dependency .d.ts files, untouched projects), building enormous types (unions with tens of thousands of members, deeply recursive conditional types), and asking the checker to infer what you could have written down.

The approach is the same as any performance work: measure first, then fix the hot spot.

Shell
npx tsc --noEmit --extendedDiagnostics   # where did the time go?
npx tsc --noEmit --explainFiles          # why is each file in the program?
npx tsc --noEmit --generateTrace trace   # per-type trace for deep investigation
AdvancedReading --extendedDiagnostics output
text
Files:              86
Lines:           58482
Identifiers:     49793
Symbols:         62474
Types:           37894
Instantiations:  35862
Memory used:    69060K
Parse time:     0.012s
Bind time:      0.004s
Check time:     0.112s
Total time:     0.130s
What’s happening
  1. --extendedDiagnostics prints counts and timings after a normal compile. This output is from a tiny two-file project — but 86 files and 58k lines were loaded, almost all of them lib.*.d.ts files describing the DOM and ES built-ins.
  2. Check time is usually the big number. If Types or Instantiations are in the millions, some generic or conditional type is being expanded far more than you'd expect — use --generateTrace and open the trace to find which.
  3. --explainFiles lists every file and why it's included (include pattern, an import, a types entry). It's the fastest way to spot a stray node_modules folder or a whole dist directory being type-checked.
  4. If Files is surprisingly high, tighten include, add exclude, and set types explicitly so only the @types packages you use are loaded.
AdvancedSpeed levers, cheapest first

The levers, from cheapest to most involved:

  • skipLibCheck: true — don't type-check .d.ts files (your dependencies' typings). Your code is still checked against them. Almost every project should use it; the only cost is not noticing conflicting or broken typings in node_modules.
  • incremental: true — save a .tsbuildinfo file so the next tsc only re-checks what changed. Watch mode and tsc -b do the same.
  • Project references — split a big program into smaller projects so a change only re-checks one of them (see Project references and composite).
  • isolatedModules — doesn't speed up tsc itself, but guarantees every file can be transpiled alone, so fast single-file tools (esbuild, SWC, tsx, Vite) can do the emitting while tsc --noEmit only checks. It errors on things those tools can't handle, e.g. TS1205 "Re-exporting a type when 'isolatedModules' is enabled requires using 'export type'."
  • Write explicit return types on exported functions — the checker (and declaration emit) doesn't have to infer them from the body; isolatedDeclarations (TS 5.5+) enforces this so .d.ts files can be generated in parallel.
  • Prefer interface extends over big intersections — interface relationships are cached, intersections are re-computed (see extends vs intersection).
  • Avoid huge unions and deep recursion in types — every operation over a union touches every member.
TypeScript
type Digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9";

type Pin4 = `${Digit}${Digit}${Digit}${Digit}`; // 10,000 members
type Pin5 = `${Digit}${Digit}${Digit}${Digit}${Digit}`; // ❌ Expression produces a union type that is too complex to represent.

const pin: Pin4 = "0420";
console.log(pin);
What’s happening
  1. A template literal type over unions produces the cross product: Digit has 10 members, so Pin4 has 10 × 10 × 10 × 10 = 10,000 string literal members. The checker builds every one.
  2. Pin5 would be 100,000 members. TypeScript refuses to build a cross product that large (the cut-off is 100,000 combinations), so it stops with TS2590 rather than freezing your editor. One more digit and you would be at a million.
  3. Even below the limit, a 10,000-member union makes every assignment and every mapped or conditional type over it slower, because they operate member by member.
  4. The practical fix is to type such values loosely (string) and validate them at runtime, or use a branded type. Keep exhaustive literal unions for genuinely small sets — status codes, event names, keys of an object.
AdvancedBackground: the Go-based TypeScript 7

Built from Rohit’s “Typescript Interview” study doc. Examples are checked with the TypeScript compiler in strict mode.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.