Learn/languages/TypeScript
Intermediate~20 min read

TypeScript

A practical guide to TypeScript's type system, covering basic types, interfaces, generics, narrowing, utility and advanced types, and the tooling that powers modern TS 5.x projects.

TypesGenericsInterfacesType Narrowing

What TypeScript Adds Over JavaScript

TypeScript is a strict superset of JavaScript that adds a static type system on top of the language you already know. Every valid .js file is a valid .ts file, but TypeScript lets you annotate values with types that are checked at compile time. The types are erased during compilation, so the JavaScript you ship at runtime is unchanged.

The payoff: bugs caught before you run code, editor autocomplete and refactoring, self-documenting function signatures, and confidence when changing large codebases. TypeScript does not add runtime type checking on its own; if you need to validate untrusted data at runtime, reach for a schema library like Zod.

// JavaScript: silent bug at runtime
function greet(name) { return "Hi " + name.toUppercase(); } // typo!

// TypeScript: error before you run
function greet(name: string): string {
  return "Hi " + name.toUpperCase(); // toUppercase would be flagged
}

Basic Types

The core primitives mirror JavaScript: string, number, boolean, plus bigint, symbol, null, and undefined.

let title: string = "TS";
let count: number = 42;
let active: boolean = true;

// Arrays: two equivalent syntaxes
let ids: number[] = [1, 2, 3];
let names: Array<string> = ["a", "b"];

// Tuple: fixed-length, ordered types
let pair: [string, number] = ["age", 30];
let rgb: [number, number, number] = [255, 0, 0];

// Enum: named set of constants
enum Direction { Up, Down, Left, Right }
let d: Direction = Direction.Up; // 0

// Prefer union of literals over enum in many cases:
type Dir = "up" | "down" | "left" | "right";

The special types deserve care. any opts out of type checking entirely (avoid it). unknown is the type-safe counterpart: you must narrow it before use. never represents values that never occur (a function that always throws, or an exhausted union). void is the absence of a return value.

let a: any = 5;      a.foo.bar;         // allowed, unsafe
let u: unknown = 5;  // u.foo;          // error: must narrow first
if (typeof u === "number") u.toFixed(2); // ok after narrowing

function fail(msg: string): never { throw new Error(msg); }
function log(msg: string): void { console.log(msg); }
Aspect any unknown
Assign anything to itYesYes
Use without narrowingYes (unsafe)No (must narrow)
Assign it to other typesYesOnly to any/unknown
RecommendedAvoidPrefer for untyped input

Type Inference

You rarely need to annotate everything. TypeScript infers types from initializers, return statements, and context. Idiomatic TS annotates function parameters and public API boundaries but lets local variables and return types be inferred.

let x = 3;              // inferred: number
const y = "hi";         // inferred: "hi" (literal type via const)
const nums = [1, 2, 3]; // inferred: number[]

function double(n: number) { return n * 2; } // return inferred as number

Interfaces vs Type Aliases

Both describe the shape of an object. interface supports declaration merging and is idiomatic for object/class contracts. type aliases can name anything — unions, tuples, primitives, mapped types — and are more flexible.

interface User {
  id: number;
  name: string;
  email?: string;        // optional
  readonly createdAt: Date; // cannot be reassigned
}

interface Admin extends User { role: "admin"; } // extension

type Point = { x: number; y: number };
type ID = string | number;               // only type can do this
type Handler = (e: Event) => void;

Rule of thumb

Use interface for object shapes and public APIs that may be extended; use type for unions, intersections, and anything not a plain object shape.

Union & Intersection Types

A union (|) means "one of these". An intersection (&) combines multiple types into one that has all their members.

type Status = "loading" | "success" | "error"; // union of literals

type Named = { name: string };
type Aged  = { age: number };
type Person = Named & Aged; // must have both name and age

const p: Person = { name: "Ada", age: 36 };

Literal Types

A literal type is an exact value: "GET" or 200. Combined with unions they model precise sets. Use as const to freeze an object into deeply readonly literal types.

type Method = "GET" | "POST" | "PUT" | "DELETE";
function request(url: string, method: Method) { /* ... */ }

const config = { retries: 3, mode: "fast" } as const;
// config.mode is "fast" (not string), config is readonly

Generics

Generics let you write reusable code that works over many types while preserving type information. A type parameter (conventionally T) is a placeholder filled in when the function or class is used.

// Generic function
function identity<T>(value: T): T { return value; }
const n = identity<number>(5); // T = number
const s = identity("hi");       // T inferred as string

// Generic function over arrays
function first<T>(arr: T[]): T | undefined { return arr[0]; }

// Generic class
class Box<T> {
  constructor(private value: T) {}
  get(): T { return this.value; }
}
const b = new Box<string>("hello");

Constraints with extends

Use extends to require that a type parameter has certain members, so you can safely use them inside.

function longest<T extends { length: number }>(a: T, b: T): T {
  return a.length >= b.length ? a : b;
}
longest([1, 2], [1, 2, 3]); // ok, arrays have length
longest("ab", "abc");         // ok, strings have length

// Constrain a key to actual keys of an object
function prop<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

Type Narrowing

Narrowing is how TypeScript refines a broad type (like a union) to something more specific within a branch of code. The compiler follows your control flow.

typeof, instanceof, and in

function format(x: string | number) {
  if (typeof x === "string") return x.trim(); // x: string here
  return x.toFixed(2);                         // x: number here
}

class Dog { bark() {} }
class Cat { meow() {} }
function speak(a: Dog | Cat) {
  if (a instanceof Dog) a.bark(); else a.meow();
}

type Fish = { swim: () => void };
type Bird = { fly: () => void };
function move(pet: Fish | Bird) {
  if ("swim" in pet) pet.swim(); else pet.fly();
}

Discriminated Unions

Give each union member a shared literal discriminant field. TypeScript narrows on it, and a never check in the default case enforces exhaustiveness.

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; side: number };

function area(s: Shape): number {
  switch (s.kind) {
    case "circle": return Math.PI * s.radius ** 2;
    case "square": return s.side ** 2;
    default:
      const _exhaustive: never = s; // errors if a case is missed
      return _exhaustive;
  }
}

Type Guards & Assertion Functions

// User-defined type guard: return type is "x is T"
function isString(x: unknown): x is string {
  return typeof x === "string";
}

// Assertion function: narrows by throwing
function assertDefined<T>(v: T | null | undefined): asserts v is T {
  if (v == null) throw new Error("Expected value");
}

Utility Types

TypeScript ships built-in generic types that transform other types. These save enormous boilerplate.

Utility Effect
Partial<T>Makes all properties optional
Required<T>Makes all properties required
Readonly<T>Makes all properties readonly
Pick<T, K>Keeps only keys K
Omit<T, K>Removes keys K
Record<K, V>Object with keys K and values V
ReturnType<F>Extracts a function's return type
Parameters<F>Tuple of a function's parameter types
Awaited<T>Unwraps a Promise type
interface User { id: number; name: string; email: string; }

type Draft   = Partial<User>;               // all optional
type Public  = Omit<User, "email">;         // { id, name }
type Summary = Pick<User, "id" | "name">;   // { id, name }
type Frozen  = Readonly<User>;
type ById    = Record<number, User>;        // { [k: number]: User }

function getUser() { return { id: 1, name: "Ada" }; }
type Got = ReturnType<typeof getUser>;      // { id: number; name: string }
type P   = Awaited<Promise<string>>;        // string

keyof, typeof & Indexed Access

keyof T yields a union of a type's keys. typeof value lifts a runtime value into its type. Indexed access T[K] looks up a property type.

interface User { id: number; name: string; }
type UserKeys = keyof User;      // "id" | "name"
type NameType = User["name"];    // string

const settings = { dark: true, fontSize: 14 };
type Settings = typeof settings; // { dark: boolean; fontSize: number }
type Values   = Settings[keyof Settings]; // boolean | number

Mapped Types

Mapped types build new types by iterating over the keys of another type. Modifiers ? and readonly can be added or removed with +/-, and keys can be remapped with as.

type Optional<T> = { [K in keyof T]?: T[K] };       // like Partial
type Mutable<T>  = { -readonly [K in keyof T]: T[K] }; // strip readonly

// Key remapping with template literal
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type UserGetters = Getters<{ name: string }>; // { getName: () => string }

Conditional Types & infer

Conditional types choose between two types based on a relationship: T extends U ? X : Y. The infer keyword captures a type from within a match.

type IsString<T> = T extends string ? true : false;
type A = IsString<"hi">;  // true
type B = IsString<number>; // false

// infer extracts the element type of an array
type ElementOf<T> = T extends (infer U)[] ? U : never;
type E = ElementOf<string[]>; // string

// A hand-rolled ReturnType
type MyReturn<F> = F extends (...args: any[]) => infer R ? R : never;

Template Literal Types

String literal types can be composed like template strings, producing unions of concrete strings — great for typed event names, routes, and CSS units.

type Color = "red" | "green";
type Shade = "light" | "dark";
type Variant = `${Shade}-${Color}`;
// "light-red" | "light-green" | "dark-red" | "dark-green"

type EventName<T extends string> = `on${Capitalize<T>}`;
type Click = EventName<"click">; // "onClick"

Classes & Access Modifiers

TypeScript classes add public (default), private, and protected modifiers, parameter properties, readonly, abstract classes, and can implements interfaces. Modern TS also supports native ECMAScript #private fields.

interface Speaker { speak(): string; }

abstract class Animal implements Speaker {
  // parameter property: declares + assigns in one line
  constructor(protected readonly name: string) {}
  abstract speak(): string;
}

class Dog extends Animal {
  #tricks = 0;             // truly private (runtime enforced)
  speak(): string { return `${this.name} says woof`; }
}

Modules & Declaration Files

TypeScript uses standard ES module import/export. Use import type for type-only imports so bundlers can drop them. Declaration files (.d.ts) describe the types of plain JavaScript libraries; community types live under @types/*.

// user.ts
export interface User { id: number; }
export function makeUser(): User { return { id: 1 }; }

// app.ts
import { makeUser } from "./user";
import type { User } from "./user"; // erased at build time

// globals.d.ts
declare global {
  interface Window { myApp: { version: string }; }
}
export {};

tsconfig Strict Options

The compiler is configured with tsconfig.json. Turning on "strict": true enables a bundle of safety flags and is strongly recommended for new projects.

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,                 // enables the flags below
    "noImplicitAny": true,
    "strictNullChecks": true,       // null/undefined are explicit
    "noUncheckedIndexedAccess": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "noEmit": true                  // let a bundler emit JS
  }
}

Why strict matters

strictNullChecks alone eliminates a huge class of "cannot read properties of undefined" runtime crashes by forcing you to handle null and undefined explicitly.

Tooling

The reference compiler is tsc. For running .ts files directly, tsx is the fast modern choice (an alternative to the older ts-node). Recent Node.js and Deno/Bun can strip TS types natively. Lint with typescript-eslint.

# Type-check the whole project (no output)
npx tsc --noEmit

# Watch mode
npx tsc --watch

# Run a TypeScript file directly
npx tsx src/index.ts

# Lint
npx eslint . --ext .ts,.tsx

Practice Exercises

  1. Model an API result as a discriminated union Result<T> with { ok: true; data: T } and { ok: false; error: string }, then write a handler that narrows on ok.
  2. Write a generic function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] that extracts one property from every element in an array.
  3. Implement a user-defined type guard isUser(x: unknown): x is User that safely validates an unknown value from JSON.parse.
  4. Build a mapped type DeepReadonly<T> that recursively marks every nested property as readonly.
  5. Use a conditional type with infer to write Unwrap<T> that returns the inner type of a Promise<T> or the type itself if not a promise.
  6. Enable "strict": true on an existing JavaScript file renamed to .ts, then fix every error using proper narrowing instead of any casts.

Section navigation