All lessons
LanguagesBeginner12 min readIn my stack

JavaScript & TypeScript

The JavaScript that matters, plus the types that stop bugs before they ship.

  • TypeScript 5
  • Types & generics
  • Strict mode
  • ES2024
Step 1/5
A function with types: price must be a number. The type checker (tsc) is on duty with its magnifying glass.

In 30 seconds

JavaScript is the language of the browser and of Node.js. TypeScript is JavaScript with a type system on top: you describe the shape of your data, and the compiler checks every use before the code runs. The types disappear at build time, so the result is ordinary JavaScript, only with far fewer surprises in production.

Why I use it

  • I use TypeScript on almost everything I build, from Next.js storefronts to a NestJS API and React Native apps, because one wrong field name should fail in the editor, not in front of a customer.
  • I rely on types as living documentation: when a backend response changes, the compiler shows me every screen and service that needs to follow.
  • I turn on strict mode from day one. Retrofitting it into a large codebase later costs far more than starting with it.
  • I still treat JavaScript fundamentals (closures, promises, the event loop) as the real foundation. TypeScript does not change how the code runs, only how safely you write it.

How it works

01JavaScript fundamentals that actually matter

Before types, understand the runtime. JavaScript has primitive values (string, number, boolean, null, undefined, bigint, symbol) and objects, which are passed by reference: two variables can point to the same object, so mutating one changes what the other sees. Use const by default and let when you must reassign; avoid var. A closure is a function that remembers the variables from the place where it was created, even after that outer function has returned. Closures power callbacks, React hooks, debouncing and private state. Equality matters too: always use === (strict equality), because == performs confusing type conversions. Finally, learn the modern syntax you will read every day: destructuring, spread, optional chaining (a?.b) and nullish coalescing (a ?? fallback), which only falls back on null or undefined, unlike || which also replaces 0 and empty strings.

ts
function makeCounter() {
  let count = 0; // private, captured by the closure
  return () => ++count;
}
 
const next = makeCounter();
next(); // 1
next(); // 2
 
const user = { name: "Ana", address: null };
const city = user.address?.city ?? "Unknown"; // "Unknown"
const items = 0 || 10; // 10  (|| replaces 0)
const safe = 0 ?? 10; // 0   (?? keeps 0)

02Async code and the event loop

JavaScript runs your code on a single thread. Slow work such as network calls, timers and file reads is handed to the browser or Node.js, and a callback is queued when it finishes. The event loop picks queued work only when the call stack is empty. There are two main queues: microtasks (promise callbacks, await continuations) and macrotasks (setTimeout, I/O, UI events). After each task, all microtasks run before the next macrotask. That is why a resolved promise logs before a setTimeout of 0 ms. async/await is syntax over promises: await pauses that function, not the whole program. Two practical rules follow. Run independent requests in parallel with Promise.all instead of awaiting them one by one, and never block the thread with heavy synchronous loops, because nothing else (clicks, other requests) can run until they finish.

ts
console.log("1 sync");
setTimeout(() => console.log("4 macrotask"), 0);
Promise.resolve().then(() => console.log("3 microtask"));
console.log("2 sync");
 
// Parallel, not sequential:
async function loadDashboard(id: string) {
  const [user, orders] = await Promise.all([
    fetch(`/api/users/${id}`).then((r) => r.json()),
    fetch(`/api/orders?user=${id}`).then((r) => r.json()),
  ]);
  return { user, orders };
}

03Types: describing the shape of data

A type is a promise about a value: this is a string, this object has an id and an email, this function returns a number. TypeScript checks those promises at compile time. You will mostly use type aliases and interfaces for object shapes, union types for 'one of these' ("draft" | "paid" | "cancelled"), and literal types for exact values. Let inference do the work for local variables, and write types explicitly at boundaries: function parameters, return types of public functions, API responses and component props. Avoid any, which switches checking off; use unknown for values you have not validated yet, because unknown forces you to check before use. The satisfies operator is useful for config objects: it checks a value against a type while keeping its precise inferred literal types.

ts
type OrderStatus = "draft" | "paid" | "cancelled";
 
interface Order {
  id: string;
  total: number;
  status: OrderStatus;
  note?: string; // optional
}
 
function label(order: Order): string {
  return `#${order.id} - ${order.status}`;
}
 
const routes = {
  home: "/",
  orders: "/orders",
} satisfies Record<string, `/${string}`>;

04Narrowing and discriminated unions

Narrowing is how TypeScript follows your checks. Inside if (typeof x === "string"), the compiler knows x is a string. It understands typeof, instanceof, the in operator, equality checks, truthiness and your own type predicates (functions that return value is Type). The most powerful pattern is the discriminated union: several object types share one literal field, such as kind or status, and a switch on that field narrows to exactly one variant. Add a never check in the default branch and the compiler will fail the build the day someone adds a new variant and forgets to handle it. This turns 'we forgot one case' bugs, very common in payment and order flows, into compile errors.

ts
type PaymentResult =
  | { kind: "success"; transactionId: string }
  | { kind: "declined"; reason: string }
  | { kind: "unknown" }; // gateway timed out
 
function message(result: PaymentResult): string {
  switch (result.kind) {
    case "success":
      return `Paid: ${result.transactionId}`;
    case "declined":
      return `Declined: ${result.reason}`;
    case "unknown":
      return "Checking with the bank...";
    default: {
      const unreachable: never = result; // new variant = compile error
      return unreachable;
    }
  }
}

05Generics and utility types

A generic is a type parameter: a placeholder that is filled in when the function or type is used. It lets you write one piece of code that works for many types without losing information. A function that fetches JSON can return Promise<T>, so the caller receives a User or an Order rather than any. Constraints (T extends { id: string }) say what the type must at least have. TypeScript ships utility types built on generics: Partial<T> makes every field optional (useful for update payloads), Pick and Omit select fields, Record<K, V> describes dictionaries, ReturnType and Awaited extract types from functions. Remember that a generic cast is not validation: data from the network should be checked at runtime with a schema library such as Zod or Valibot before you trust its type.

ts
async function getJson<T>(url: string): Promise<T> {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return (await res.json()) as T; // validate with a schema in real code
}
 
function byId<T extends { id: string }>(items: T[]): Record<string, T> {
  return Object.fromEntries(items.map((item) => [item.id, item]));
}
 
type User = { id: string; email: string; name: string };
type UserUpdate = Partial<Omit<User, "id">>;
 
const user = await getJson<User>("https://api.example.com/me");

06Strict mode and tsconfig essentials

tsconfig.json controls how strict the compiler is and how it understands modules. The single most important option is strict: true, which enables strictNullChecks (null and undefined must be handled), noImplicitAny and several other checks. Since TypeScript 6.0, strict is the default for new configs, and TypeScript 7.0 (the new Go-based compiler, released in 2026 and roughly ten times faster on large builds) keeps that default. Add noUncheckedIndexedAccess so array[i] and dictionary lookups include undefined. For apps built by a bundler (Next.js, Vite) use module "esnext" with moduleResolution "bundler"; for Node.js packages use "nodenext". Turn on verbatimModuleSyntax so type-only imports are explicit, and set noEmit when a bundler does the actual compilation. Run tsc --noEmit in CI so a type error blocks the merge.

json
{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2024", "DOM", "DOM.Iterable"],
    "module": "esnext",
    "moduleResolution": "bundler",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "verbatimModuleSyntax": true,
    "isolatedModules": true,
    "noEmit": true,
    "skipLibCheck": true,
    "paths": { "@/*": ["./src/*"] }
  },
  "include": ["src"]
}

Use it when

  • Any project that will live longer than a few weeks or be touched by more than one person.
  • Code that crosses boundaries: API clients, shared packages, monorepos where a backend and several frontends share types.
  • Domains with many states and rules, such as orders, payments and subscriptions, where discriminated unions catch missed cases.
  • Large refactors: the compiler gives you a complete list of everything a change breaks.

Skip it when

  • A ten-line throwaway script or a quick prototype you will delete tomorrow; plain JavaScript (optionally with JSDoc types) is enough.
  • As a replacement for runtime validation: types vanish at build time, so user input and API responses still need checking.

Alternatives

  • Plain JavaScript + JSDoc

    Type comments checked by the TypeScript engine without a build step; good for small libraries and scripts.

  • Flow

    Meta's type checker for JavaScript; still used in some React Native internals, but the ecosystem has largely moved to TypeScript.

  • Kotlin/JS, Dart, ReScript

    Languages that compile to JavaScript with stronger guarantees, at the cost of a smaller ecosystem and an unfamiliar syntax for most web teams.

Best practices

  1. Enable strict and noUncheckedIndexedAccess at the start of a project; run tsc --noEmit in CI.
  2. Type the boundaries explicitly (props, function signatures, API responses) and let inference handle local variables.
  3. Prefer unknown over any, and validate external data with a schema library before trusting its type.
  4. Model states with discriminated unions and add a never check so new cases cannot be forgotten.
  5. Keep one source of truth for shared types, such as a shared package or types generated from an OpenAPI or database schema.
  6. Use Promise.all for independent async work and always handle rejected promises.

Common mistakes

  • Sprinkling any and as casts to silence errors, which hides exactly the bugs TypeScript was meant to catch.
  • Trusting a typed API response without runtime validation, then crashing when the backend sends null.
  • Awaiting independent requests one after another, making a page several times slower than it needs to be.
  • Using || for defaults and accidentally replacing valid values like 0 or an empty string.
  • Forgetting that objects are shared by reference and mutating state that other code still reads.

Deep dive

07Structural typing, excess property checks and branded types

TypeScript compares types by shape, not by name. If an object has every property a type requires, it is assignable, even if it was declared as something else. That is called structural typing, and it is why a UserId and an OrderId that are both plain strings can be swapped without a single error. Excess property checks are a narrow exception: an object literal written directly where a type is expected may not contain unknown keys, but the same object passed through a variable is accepted. When two values share a shape but must never be mixed, use a branded type: intersect the primitive with a unique marker property that exists only at the type level. A small constructor function becomes the only way to create the brand, so validation and typing live in one place. Brands cost nothing at runtime, because the marker is erased with the rest of the types.

ts
declare const brand: unique symbol;
type Brand<T, B extends string> = T & { readonly [brand]: B };
 
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
 
function toUserId(raw: string): UserId {
  if (!/^usr_[a-z0-9]+$/.test(raw)) throw new Error("Invalid user id");
  return raw as UserId; // the only cast, guarded by validation
}
 
declare function cancelOrder(id: OrderId): Promise<void>;
 
const id = toUserId("usr_42");
// cancelOrder(id); // error: UserId is not assignable to OrderId

08Type-level programming: conditional, mapped and template literal types

Utility types such as Partial and ReturnType are not magic; they are built from three features you can use yourself. A conditional type (T extends U ? X : Y) chooses a type based on another, and infer captures a piece of the matched type, which is how ReturnType pulls out a function's result. When T is a union, a conditional type distributes over each member separately; wrap both sides in brackets ([T] extends [U]) to stop that. A mapped type ({ [K in keyof T]: ... }) transforms every property, and key remapping with as can rename or filter keys. Template literal types build string types, such as `on${Capitalize<K>}` for event handler names. Use these in library and infrastructure code, such as typed API clients, form helpers and event emitters, where one clever type saves hundreds of hand-written ones. In application code, keep them rare: a type that needs a whiteboard to explain is a maintenance cost.

ts
// Pick only the keys whose values are functions
type MethodKeys<T> = {
  [K in keyof T]: T[K] extends (...args: never[]) => unknown ? K : never;
}[keyof T];
 
// Turn { click: MouseEvent } into { onClick: (e: MouseEvent) => void }
type Handlers<E> = {
  [K in keyof E & string as `on${Capitalize<K>}`]: (event: E[K]) => void;
};
 
type Unwrap<T> = T extends Promise<infer V> ? V : T;
 
type A = Handlers<{ click: MouseEvent; keydown: KeyboardEvent }>;
// { onClick: (event: MouseEvent) => void; onKeydown: (event: KeyboardEvent) => void }
type B = Unwrap<Promise<number>>; // number

09Soundness gaps you should know about

TypeScript is deliberately not fully sound: some programs type-check and still fail at runtime. Knowing the gaps tells you where to stay alert. Arrays are covariant, so a Dog[] can be passed where an Animal[] is expected, and the callee can push a Cat into it. Method parameters declared with method syntax (save(x: T): void) are checked bivariantly even under strictFunctionTypes; property syntax (save: (x: T) => void) is checked strictly, so prefer it in interfaces you control. Type assertions (as), the non-null operator (!) and any all bypass checks. Index access without noUncheckedIndexedAccess pretends every key exists. Narrowing of an object property is not undone by function calls, so a property checked before an await or a callback may have changed by the time you use it. Finally, enums and namespaces generate runtime code, unlike almost everything else; the erasableSyntaxOnly option (TypeScript 5.8) forbids them so the code can run under plain type stripping, as Node.js now does by default.

ts
interface Animal { name: string }
interface Dog extends Animal { bark(): void }
 
function addCat(list: Animal[]) {
  list.push({ name: "Cat" }); // allowed: arrays are covariant
}
 
const dogs: Dog[] = [{ name: "Rex", bark() {} }];
addCat(dogs);
dogs[1].bark(); // type-checks, crashes at runtime
 
// Safer: accept a readonly view when you only read
function names(list: readonly Animal[]) {
  return list.map((a) => a.name);
}

10Compiler performance in large codebases

On a big monorepo, type-checking time becomes a developer-experience problem. Start by measuring: tsc --extendedDiagnostics prints check time, memory and the number of types created, and --generateTrace writes a trace you can open in a browser performance tool to find the files and types that dominate. Common causes are huge unions (thousands of string literals), deeply recursive conditional types, and barrel files that make every module import everything. Split the repo with project references: each package has composite: true and declaration output, and tsc -b builds only what changed, reusing .tsbuildinfo files from incremental builds. isolatedDeclarations (TypeScript 5.5) requires explicit types on exports, so other tools can generate .d.ts files in parallel without running the checker. Prefer interfaces that extend each other over large intersections, because the compiler caches interface relationships better. The native TypeScript 7 compiler makes most of this much faster, but the same structural advice still applies.

bash
# Where does the time go?
npx tsc --noEmit --extendedDiagnostics
npx tsc --noEmit --generateTrace ./ts-trace
 
# Monorepo: build only what changed, in dependency order
npx tsc -b packages/api packages/web --verbose
 
# packages/shared/tsconfig.json (excerpt)
# "composite": true, "declaration": true,
# "incremental": true, "isolatedDeclarations": true

Real-world scenarios

01

The backend renamed a field and production broke

Problem

The API started returning totalAmount instead of total. The frontend compiled fine, because the response was typed with a cast, and the order screen showed NaN to every customer until a hotfix.

How to solve it

Treat the network as untrusted input. Define the response with a schema (Zod or Valibot) and infer the TypeScript type from it with z.infer, so one definition drives both. Parse every response in a single API adapter layer: on failure, log the issue path and the raw payload to error tracking and show a friendly error instead of NaN. Better still, generate the types from the backend's OpenAPI document in CI, so a renamed field breaks the frontend build before deploy. Add a contract test that runs the schema against a recorded real response. Casts like as Order should then appear only inside that adapter.

02

A legacy codebase with 3,000 type errors under strict mode

Problem

The team wants strict mode, but turning it on produces thousands of errors across hundreds of files. Nobody can stop feature work for a month to fix them.

How to solve it

Enable strict in the main tsconfig so all new code is held to it, and record the existing errors as a baseline instead of fixing them at once. A script runs tsc, normalises the output by file and message, and fails CI only on errors that are not in the baseline. Each time someone touches a file, they fix its errors and the baseline shrinks. Track the count on a dashboard. Start with high-value modules (API layer, money and order logic) and leave old, rarely edited screens for last. Ban new any and ts-ignore with an ESLint rule, allowing ts-expect-error only with a written reason.

03

Type-checking takes four minutes in CI

Problem

A monorepo with an API, two web apps and shared packages runs a single tsc over everything. CI is slow, and the editor freezes for seconds after each change in a shared package.

How to solve it

Measure first with --extendedDiagnostics and --generateTrace to see whether the cost is spread out or caused by a few pathological types. Then split the repo into project references: shared packages get composite and declaration output, apps reference them, and CI runs tsc -b so unchanged packages are skipped using cached .tsbuildinfo files. Remove barrel files that re-export entire packages, replace giant generated unions with simpler string types where precision is not needed, and add explicit return types to exported functions so the checker does less inference across packages. Upgrading to the native TypeScript 7 compiler is then a further multiplier rather than the only fix.

Cheat sheet

Type-check without emitting files
npx tsc --noEmit
Find slow types
npx tsc --noEmit --extendedDiagnostics --generateTrace ./ts-trace
Incremental monorepo build
npx tsc -b packages/* --verbose
Branded id type
type UserId = string & { readonly __brand: "UserId" };
Exhaustive switch guard
default: { const _x: never = value; return _x; }
Infer a type from a runtime schema
type User = z.infer<typeof UserSchema>;
Keep literal types in config
const config = { mode: "live" } as const satisfies Config;
Block inference from one argument
function pick<T>(items: T[], fallback: NoInfer<T>): T
Auto-dispose a resource
await using conn = await pool.connect(); // needs Symbol.asyncDispose
Run a .ts file in recent Node.js
node src/script.ts // types stripped, not checked

In my real projects

  • The multi-tenant POS SaaS I co-lead is TypeScript end to end: a NestJS 11 API with Prisma over 181 database models, React 19 apps, an Electron till and a Next.js site in one monorepo.
  • The pet-supplies storefront runs Next.js 16 with TypeScript in strict mode across roughly 600 TS/TSX files.
  • In a coffee-ordering React Native app, a post-edit hook runs tsc and ESLint and reports only new errors against a recorded baseline, so type debt can only shrink.
  • The same app normalises backend responses in a single typed adapter layer, so a backend field rename never touches a screen.

Questions & answers

Q1What is the difference between any and unknown?

Both can hold any value, but they behave in opposite ways. any switches type checking off: you can call methods on it, pass it anywhere, and errors only appear at runtime. It also spreads, because whatever you derive from any is any too. unknown is the safe version: the compiler lets you store anything in it but refuses to let you use it until you narrow it with typeof, instanceof, a type predicate or a schema validation. I use unknown for JSON from the network, caught errors and third-party callbacks, and treat any as a code smell that needs a comment explaining why.

Q2Explain the event loop. Why does a promise callback run before setTimeout(fn, 0)?

JavaScript runs on one thread with a call stack. Asynchronous work is delegated to the host (the browser or Node.js), and when it completes, a callback is queued. The event loop takes work from queues only when the stack is empty. Promise callbacks and await continuations go into the microtask queue; timers, I/O and UI events are macrotasks. After every macrotask, the engine drains the whole microtask queue before taking the next macrotask. So after the current script finishes, the resolved promise runs first, then the timer. The practical lesson: long synchronous work blocks everything, and an endless chain of microtasks can starve rendering.

Q3Does TypeScript make my code run faster or safer at runtime?

Not directly. TypeScript is erased at build time, so the JavaScript that runs is the same as what you would write by hand, with no extra checks and no speed gain. The benefit is earlier feedback: a whole class of bugs (wrong property names, missing null handling, forgotten union cases, wrong argument order) is caught in the editor or CI instead of production. For real runtime safety at the edges, such as form input, API responses and environment variables, you still validate data with a schema library. The combination works best when the schema also produces the TypeScript type, so there is a single source of truth.

Q4What are generics and when would you use them?

Generics are type parameters. They let a function, class or type work with many types while keeping the exact type information. Instead of a fetch helper that returns any, getJson<T> returns Promise<T>, so the caller gets a typed User or Order. Constraints like T extends { id: string } say what the type must at least contain, which lets the function safely read item.id. I reach for generics in reusable building blocks: API clients, repositories, form helpers, list components and state stores. If a function only ever handles one type, a generic adds noise and nothing else, so I don't use one there.

Q5interface or type: which should I use?

For object shapes they are nearly interchangeable, and consistency within a codebase matters more than the choice. Interfaces can be extended with extends and can be merged by declaring them twice, which is useful for augmenting library types such as a global Window or a framework's request object. Type aliases are more flexible: they can describe unions, tuples, mapped and conditional types, which interfaces cannot. My rule is simple: type aliases for unions and computed types, interfaces for public object contracts that others might extend. Whichever you choose, avoid mixing styles randomly, because reviewers then have to wonder whether the difference is intentional.

Q6How would you introduce TypeScript into an existing JavaScript project?

Gradually. First add a tsconfig with allowJs and checkJs off, so TypeScript compiles alongside the existing files without errors. Rename files to .ts one module at a time, starting with leaf utilities and the API layer, because types there flow into everything else. Turn on strict early for new files, even if old ones need temporary suppressions, and track the count of ts-expect-error comments so it only goes down. Add tsc --noEmit to CI immediately so new type errors cannot be merged. A post-edit check that reports only new errors against a baseline keeps the team moving without having to fix everything at once.

Senior-level questions

S1How do you share types between a backend and several frontends without them drifting apart?

There are three main approaches, each with trade-offs. A shared TypeScript package in a monorepo is the simplest and gives instant feedback, but it couples release cycles and only works when every consumer is TypeScript. Generating types from a contract, such as an OpenAPI document or a GraphQL schema, works across languages and versions, at the cost of a codegen step and a contract that must be kept honest; I make CI fail if the generated output is out of date. End-to-end typed RPC like tRPC gives the best ergonomics but ties clients tightly to one server implementation. Whatever the choice, types alone are not enough at the network boundary: I still validate responses at runtime in one adapter layer, because a deployed backend and an old mobile client can disagree no matter what the compiler said.

S2Enums, union of string literals or const objects: which do you choose and why?

For most application code I choose a union of string literals, or a const object with a derived union type when I also need the values at runtime, such as for a dropdown. Unions are erased completely, serialise naturally to JSON, narrow well in switches and work under erasableSyntaxOnly and Node's type stripping. TypeScript enums generate runtime objects, numeric enums accept any number, and const enums cause problems with isolated compilation, so they fit poorly in modern toolchains. The const object pattern gives the same benefits as an enum: autocomplete, a single source of values, iteration with Object.values, without the drawbacks. The one place enums still appear is older codebases, where consistency with existing code can matter more than the ideal choice.

S3Where does the type system stop helping, and how do you design around those limits?

Types describe shape, not behaviour or truth. They cannot prove a list is sorted, that an email really exists, that a price is non-negative, or that external data matches its declared type. They also have deliberate holes: casts, any, covariant arrays and unchecked index access. I design around this in layers. At boundaries, runtime schemas turn unknown input into trusted types. Inside the domain, branded types and smart constructors encode invariants such as positive amounts or validated ids, so invalid values cannot be created without passing a check. Discriminated unions make illegal states unrepresentable. Lint rules ban the escape hatches, or require a comment for them. Then tests cover what remains: business rules, ordering, timing and integration with real services.

Glossary

Structural typing
Types are compatible when their shapes match, regardless of the names they were declared with.
Type erasure
All type annotations are removed at build time, so types never exist when the JavaScript runs.
Narrowing
The compiler refining a broad type to a more specific one after a check such as typeof or a switch.
Discriminant
A shared literal field, such as kind, that tells variants of a union apart.
Branded type
A primitive combined with a type-only marker so values with the same shape cannot be mixed up.
Conditional type
A type that picks one of two results depending on whether one type extends another.
Variance
How subtyping of a container or function relates to subtyping of the types inside it.
Declaration file (.d.ts)
A file containing only type information for JavaScript code, used by libraries and project references.
Project references
Splitting a codebase into separately built TypeScript projects so only changed parts are rechecked.
Microtask
A queued job, such as a promise callback, that runs before the event loop takes the next task.

Have a product in mind?Let's build it.

Tell me about your idea, your product or your team. I usually reply within one working day.