Guide

TypeScript fundamentals explained

TypeScript is JavaScript with an optional static type system layered on top. You write .ts or .tsx files, the TypeScript compiler (tsc) checks shapes and contracts at build time, and the output is plain JavaScript browsers and Node.js already run. The payoff is not academic purity — it is catching entire classes of bugs (wrong property names, missing null checks, mismatched API payloads) before they reach production, while your editor surfaces autocomplete and inline documentation. This guide covers the core type vocabulary, the patterns teams actually use in modern web apps and REST APIs, and how to adopt TypeScript incrementally without freezing your codebase in any escape hatches.

Why TypeScript exists

JavaScript is dynamically typed: a variable can hold a number at one moment and a string the next. That flexibility speeds up early prototyping but scales poorly. Refactoring a 40-field API response object by hand is error-prone; a typo in user.emial fails silently until a user reports a blank screen. TypeScript adds structural typing — types describe the shape of values, and the compiler rejects code that violates those shapes.

Crucially, TypeScript types are erased at runtime. There is no performance penalty in the browser beyond whatever bundler you already use. Types exist only during development and CI. That is why TypeScript pairs naturally with CI pipelines that run tsc --noEmit on every pull request: broken contracts fail the build instead of shipping to users.

When TypeScript is worth the cost

Small scripts and throwaway prototypes may not need types. Once multiple developers touch the same modules, or you integrate with external HTTP and database schemas, the upfront typing cost pays back quickly. Teams shipping React, Vue, Svelte, or Node backends overwhelmingly standardize on TypeScript because the ecosystem ships first-class type definitions (@types/* on npm) for popular libraries.

Core types and annotations

Primitive types mirror JavaScript: string, number, boolean, bigint, symbol, plus null and undefined. Arrays are written string[] or Array<string>. Tuples fix arity and position: [string, number] is a pair, not a general array.

You annotate variables explicitly when inference is insufficient:

let port: number = 3847;
const labels: readonly string[] = ["draft", "published"];

function formatLamports(lamports: bigint): string {
  return (Number(lamports) / 1e9).toFixed(9);
}

TypeScript often infers types from initializers — if you write const x = 42, x is number without an annotation. Prefer inference for locals; annotate function parameters and public API boundaries where callers need a contract.

Unions, literals, and optional fields

A union expresses “one of these”: type Status = "pending" | "settled" | "failed". String literal unions replace fragile string constants. Optional properties use ?: email?: string means the field may be absent (typed as string | undefined).

unknown is the type-safe counterpart to any. Values typed as unknown must be narrowed before use — ideal for JSON parsed from cross-origin fetch responses where you cannot trust the wire format blindly.

Interfaces vs type aliases

Both describe object shapes. Interfaces are extendable and merge when declared twice (useful for augmenting third-party modules). Type aliases can name unions, intersections, and mapped types interfaces cannot express as cleanly.

interface User {
  id: string;
  displayName: string;
  role: "admin" | "member";
}

type ApiResult<T> =
  | { ok: true; data: T }
  | { ok: false; error: string };

For plain data objects, either works — pick one style per project and stay consistent. Reach for type aliases when modeling discriminated unions (the ok flag above lets TypeScript narrow data vs error inside if (result.ok) branches). That pattern mirrors how well-designed REST error responses separate success and failure payloads.

Generics: reusable type parameters

Generics let you write functions and types that work across shapes without falling back to any. A fetchJson<T>(url) helper can return Promise<T> while callers supply the expected schema:

async function fetchJson<T>(url: string): Promise<T> {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json() as T; // caller owns validation
}

interface Balance { lamports: number; }
const bal = await fetchJson<Balance>("/api/balance");

Generics appear everywhere in utility types: Record<string, number>, Partial<User> for patch updates, Pick<User, "id"> for DTOs. Libraries like React use them heavily (useState<number>). The mental model is simple: generics are function parameters for types.

Constraints

You can bound generics: <T extends { id: string }> ensures T always has an id. That prevents calling sortById on arbitrary objects while still accepting any type with a string id field.

Type narrowing and control flow

TypeScript tracks how runtime checks refine types inside branches. After if (typeof x === "string"), x is a string in that block. instanceof, in checks, and user-defined type guards (function isUser(v: unknown): v is User) let you parse untrusted input safely.

Discriminated unions are the idiomatic pattern for state machines and API results: a shared literal field (kind, type, ok) tells the compiler which other fields exist. Exhaustiveness checking with switch and a final never arm catches missing cases when you add a new variant later — a refactor safety net plain JavaScript lacks.

Nullability and strict mode

With strictNullChecks enabled (part of "strict": true in tsconfig.json), null and undefined are not assignable to other types unless you explicitly allow them. That forces you to handle absent values instead of assuming every object field exists — a common source of production crashes in wallet and payment UIs where RPC responses may be partial during network errors.

Project setup and compiler options

A minimal Node or Vite project needs typescript as a dev dependency, a tsconfig.json, and a build step (tsc, esbuild, or bundler integration). Key options:

  • strict: true — enables the full safety bundle; turn this on for new projects.
  • noUncheckedIndexedAccess — array and record index lookups include | undefined; catches off-by-one assumptions.
  • moduleResolution: "bundler" — matches modern ESM tooling (Vite, Next.js App Router).
  • paths — alias imports like @/components (your bundler must mirror the same map).

Run the compiler in CI even if you transpile with esbuild or SWC for speed — those tools strip types without validating them. tsc --noEmit is the canonical typecheck-only command and belongs beside your unit tests in every pipeline.

Incremental adoption in JavaScript codebases

Rename .js to .ts file by file. Enable allowJs and checkJs optionally for JSDoc-typed legacy modules. Avoid mass @ts-ignore — each ignore is debt. Prefer unknown plus a narrowing function at integration boundaries (third-party scripts, JSON columns from SQL queries) over sprinkling any.

TypeScript in frontend and backend

On the frontend, TypeScript types props, hooks, and event handlers in component frameworks. It does not replace runtime validation for form input — pair typed models with schema validators (Zod, Valibot) at API boundaries so server and client agree on shapes. Typed components also improve accessibility work: discriminated unions for button vs link props prevent invalid attribute combinations at compile time.

On the backend, TypeScript models request bodies, database rows, and queue messages. Share types via a monorepo package or OpenAPI-generated clients so your frontend does not drift from the API. For high-throughput services, compile to JavaScript with tsc or bundle with esbuild; for serverless, tree-shake unused code and keep cold-start bundles small.

Common pitfalls

  • Overusing any — defeats the purpose; use unknown and narrow.
  • Trusting as casts — assertions bypass checking; validate external data at runtime.
  • Ignoring strict mode — loosening compiler flags to greenwash CI hides real bugs.
  • Types without tests — types prove consistency, not behavior; still test business logic.
  • Duplicated DTOs — generate or share types between client and server instead of copy-pasting interfaces.

Key takeaways

  • TypeScript adds compile-time structure to JavaScript without changing runtime behavior — types erase after build.
  • Master unions, interfaces, generics, and narrowing before advanced mapped types; they cover most day-to-day code.
  • Enable strict and run tsc --noEmit in CI alongside your test suite.
  • Model API success and failure with discriminated unions; pair static types with runtime validators at system boundaries.
  • Adopt incrementally — typed modules compound safety as your web app or API surface grows.

Related reading