TypeScript

The TypeScript type system

TypeScript is a statically typed superset of JavaScript. It compiles to plain JavaScript and adds zero runtime overhead — types exist only at compile time. Every valid JavaScript file is also a valid TypeScript file.

Structural typing

TypeScript uses structural typing (duck typing), not nominal typing. Two types are compatible if they have the same shape — the name of the type doesn't matter, only the structure.

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

function logPoint(p: Point) {
    console.log(`${p.x}, ${p.y}`);
}

// This works — the object has the right shape even though
// it was never declared as a Point
const coord = { x: 3, y: 7, z: 0 };
logPoint(coord); // ✓ extra properties are fine when passing via variable

Compilation and tsconfig

The TypeScript compiler (tsc) type-checks your code and emits JavaScript. A tsconfig.json at the project root controls both.

// tsconfig.json — common options
{
  "compilerOptions": {
    "target": "ES2022",        // JS version to emit
    "module": "NodeNext",      // module system
    "strict": true,            // enables all strict checks (recommended)
    "noUncheckedIndexedAccess": true,  // arr[0] is T | undefined
    "outDir": "./dist",
    "rootDir": "./src"
  }
}

// strict: true enables:
// - strictNullChecks  (null/undefined are not assignable to other types)
// - strictFunctionTypes
// - strictPropertyInitialization
// - noImplicitAny     (error when type can't be inferred)
// - noImplicitThis

Primitive and special types

Primitives

let name:    string  = "Alice";
let age:     number  = 30;        // int and float are both number
let active:  boolean = true;
let big:     bigint  = 100n;
let sym:     symbol  = Symbol("key");

// Arrays
let nums:    number[]       = [1, 2, 3];
let strs:    Array<string>  = ["a", "b"];  // generic form — same thing

// Tuple — fixed-length, fixed-type array
let pair:    [string, number] = ["Alice", 30];
let rgb:     [number, number, number] = [255, 128, 0];

// Optional tuple element
let opt:     [string, number?] = ["hello"];  // second element optional

Enums

// Numeric enum — values auto-increment from 0
enum Direction { Up, Down, Left, Right }
Direction.Up    // 0
Direction.Down  // 1

// String enum — all values must be explicit
enum Status {
    Active  = "ACTIVE",
    Inactive = "INACTIVE",
    Pending = "PENDING",
}

// const enum — erased at compile time, inlined as literals (faster)
const enum Axis { X = "x", Y = "y", Z = "z" }
const a = Axis.X;  // compiles to: const a = "x";

Special types

TypeMeaningUse when
anyOpts out of type checking entirelyMigrating JS, last resort only
unknownLike any but forces you to narrow before useExternal data, API responses, catch clauses
neverA value that can never existExhaustive checks, functions that throw/loop forever
voidAbsence of a return valueFunction return type when nothing is returned
nullIntentional absenceWith strictNullChecks on, must be handled explicitly
undefinedNot yet assignedOptional properties, uninitialised state
// unknown — must narrow before use
function parse(raw: unknown): number {
    if (typeof raw === "number") return raw;   // narrowed to number
    if (typeof raw === "string") return parseFloat(raw);
    throw new Error("unsupported type");
}

// never — exhaustive check: TS errors if you add a new Status without handling it
function assertNever(x: never): never {
    throw new Error("Unhandled case: " + x);
}

function handle(s: Status) {
    switch (s) {
        case Status.Active:   return "active";
        case Status.Inactive: return "inactive";
        case Status.Pending:  return "pending";
        default: return assertNever(s); // errors if Status gets a new variant
    }
}

Interfaces and type aliases

interface

Interfaces describe the shape of an object. They can be extended and can be re-opened (declaration merging) — two interface declarations with the same name merge into one.

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

// Extending
interface Admin extends User {
    role: "superadmin" | "moderator";
}

// Declaration merging — useful for augmenting library types
interface Window {
    myPlugin: () => void;
}

// Index signature — object with unknown keys
interface StringMap {
    [key: string]: string;
}

type alias

Type aliases can name any type — objects, primitives, unions, intersections, tuples, mapped types. Unlike interfaces, they cannot be re-opened.

type ID = string | number;
type Point = { x: number; y: number };
type Callback = (err: Error | null, result: string) => void;

// Extending a type alias uses intersection
type AdminUser = User & { role: string };

interface vs type

interfacetype alias
Object shapes
Primitives / unions / tuples
Extendextends& intersection
Declaration merging✓ (automatic)
Use forPublic API shapes, library augmentationUnions, computed types, everything else

Functions

Typed parameters and return types

// TypeScript infers return type — explicit annotation is optional but clear
function add(a: number, b: number): number {
    return a + b;
}

// Arrow function
const multiply = (a: number, b: number): number => a * b;

// Optional and default parameters
function greet(name: string, greeting?: string): string {
    return `${greeting ?? "Hello"}, ${name}`;
}

// Rest parameters
function sum(...nums: number[]): number {
    return nums.reduce((a, b) => a + b, 0);
}

Function types and signatures

// Function type as a type alias
type Transformer<T> = (value: T) => T;
const double: Transformer<number> = n => n * 2;

// Typing callback parameters
function process(items: string[], callback: (item: string, index: number) => void) {
    items.forEach(callback);
}

// Object with call signature
interface Formatter {
    (value: string): string;    // call signature
    locale: string;             // also has a property
}

Overloads

Overloads let you declare multiple call signatures for a function that behaves differently depending on argument types. The last signature (the implementation) is not visible to callers.

function format(value: string): string;
function format(value: number, decimals?: number): string;
function format(value: string | number, decimals = 2): string {
    if (typeof value === "string") return value.trim();
    return value.toFixed(decimals);
}

format("  hello  ");   // "hello"
format(3.14159, 2);    // "3.14"

Union, intersection, and literal types

Union types

A union type (A | B) means a value can be one of the listed types. You must narrow before accessing type-specific properties.

type StringOrNumber = string | number;

function display(val: StringOrNumber) {
    // Must narrow before calling string-specific methods
    if (typeof val === "string") {
        return val.toUpperCase();
    }
    return val.toFixed(2);
}

Intersection types

An intersection type (A & B) means a value must satisfy BOTH types simultaneously. Used to merge object types.

type Timestamped = { createdAt: Date; updatedAt: Date };
type WithId      = { id: number };

type Entity = Timestamped & WithId;
// Entity has: id, createdAt, updatedAt

Literal types

A literal type constrains a value to a specific, exact value. Combine with unions to model a fixed set of allowed values — a safer alternative to enums for string constants.

type Direction = "north" | "south" | "east" | "west";
type DiceRoll  = 1 | 2 | 3 | 4 | 5 | 6;
type Alignment = "left" | "center" | "right";

function move(dir: Direction, steps: number) { /* ... */ }
move("north", 3);   // ✓
move("up", 3);      // ✗ Type '"up"' is not assignable to type 'Direction'

// as const — freezes an object/array's types to their literal values
const ROUTES = {
    home: "/",
    about: "/about",
    lang: "/lang",
} as const;
// typeof ROUTES.home  →  "/"  (not string)

Generics

Generics let you write reusable code that works with any type while still preserving type safety. The type parameter is a placeholder filled in at the call site.

Generic functions

// Without generics you'd need any — losing all type info
function identity<T>(value: T): T {
    return value;
}

identity("hello");   // T inferred as string → returns string
identity(42);        // T inferred as number → returns number
identity<boolean>(true); // explicit

// Multiple type parameters
function zip<A, B>(a: A[], b: B[]): [A, B][] {
    return a.map((item, i) => [item, b[i]]);
}
zip(["a", "b"], [1, 2]); // [["a", 1], ["b", 2]]

Generic interfaces and types

interface ApiResponse<T> {
    data:    T;
    status:  number;
    message: string;
}

type Result<T, E = Error> =
    | { ok: true;  value: T }
    | { ok: false; error: E };

// Usage
const res: ApiResponse<User[]> = await fetchUsers();
const r:   Result<number>      = { ok: true, value: 42 };

Constraints

Use extends to constrain what types a type parameter can be. This lets you access properties that are guaranteed to exist on the type.

// T must have a length property
function longest<T extends { length: number }>(a: T, b: T): T {
    return a.length >= b.length ? a : b;
}

longest("hello", "hi");       // ✓ strings have .length
longest([1, 2, 3], [1, 2]);   // ✓ arrays have .length

// keyof constraint — T must be a key of the object
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
    return obj[key];
}

const user = { name: "Alice", age: 30 };
getProperty(user, "name");   // string
getProperty(user, "age");    // number
getProperty(user, "email");  // ✗ "email" is not a key of the object

Generic defaults

interface Paginated<T, Meta = Record<string, unknown>> {
    items:    T[];
    total:    number;
    page:     number;
    meta:     Meta;
}

// If you don't provide Meta, it defaults to Record<string, unknown>
type UserPage = Paginated<User>;

Utility types

TypeScript ships a set of built-in generic types that transform existing types. These are the most commonly used ones.

Object transformation

UtilityDescription
Partial<T>Makes all properties of T optional
Required<T>Makes all properties of T required
Readonly<T>Makes all properties of T read-only
Pick<T, K>Creates a type with only the keys K from T
Omit<T, K>Creates a type with all keys of T except K
Record<K, V>Object type with keys K and values V
interface User {
    id:      number;
    name:    string;
    email:   string;
    role:    "admin" | "user";
}

type UserUpdate  = Partial<User>;          // all fields optional
type PublicUser  = Omit<User, "role">;     // remove role
type UserPreview = Pick<User, "id"|"name">; // only id and name
type UserMap     = Record<string, User>;    // { [key: string]: User }
type FrozenUser  = Readonly<User>;          // no reassignment

Function and inference utilities

UtilityDescription
ReturnType<T>Infers the return type of a function type
Parameters<T>Infers the parameter types as a tuple
InstanceType<T>Infers the instance type of a constructor
Awaited<T>Unwraps a Promise<T> to T
NonNullable<T>Removes null and undefined from T
async function fetchUser(id: number): Promise<User> { /* ... */ }

type FetchResult = Awaited<ReturnType<typeof fetchUser>>;  // User
type FetchParams = Parameters<typeof fetchUser>;           // [number]

// Practical: derive type from existing value so they stay in sync
const defaultConfig = { host: "localhost", port: 3000, debug: false };
type Config = typeof defaultConfig;  // { host: string; port: number; debug: boolean }

Type narrowing

Narrowing is the process of refining a union or broad type to a more specific type based on runtime checks. TypeScript's control flow analysis tracks these checks automatically.

typeof and instanceof

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

function unwrap(val: Date | string): string {
    if (val instanceof Date) return val.toISOString();
    return val;
}

in operator

interface Cat { meow(): void; }
interface Dog { bark(): void; }

function makeSound(animal: Cat | Dog) {
    if ("meow" in animal) {
        animal.meow();  // narrowed to Cat
    } else {
        animal.bark();  // narrowed to Dog
    }
}

Discriminated unions

A discriminated union is a union where each member has a common literal property (the discriminant) that uniquely identifies the type. This is the most robust narrowing pattern.

type Shape =
    | { kind: "circle";    radius: number }
    | { kind: "rectangle"; width: number; height: number }
    | { kind: "triangle";  base: number; height: number };

function area(shape: Shape): number {
    switch (shape.kind) {
        case "circle":    return Math.PI * shape.radius ** 2;
        case "rectangle": return shape.width * shape.height;
        case "triangle":  return 0.5 * shape.base * shape.height;
    }
}

// TS errors if you add a new Shape variant and forget to handle it
// (when combined with a never check at the default case)

Type predicates and assertion functions

// Type predicate — the return type tells TS what's true if the function returns true
function isString(val: unknown): val is string {
    return typeof val === "string";
}

function process(val: unknown) {
    if (isString(val)) {
        val.toUpperCase();  // TS knows val is string here
    }
}

// Assertion function — tells TS that the condition is true after the call
function assertDefined<T>(val: T | null | undefined): asserts val is T {
    if (val == null) throw new Error("Expected a value, got null/undefined");
}

const user: User | null = getUser();
assertDefined(user);
user.name; // TS now knows user is User, not null

Classes

Access modifiers

class BankAccount {
    readonly id:         string;          // can only be set in constructor
    public  owner:       string;          // default — accessible anywhere
    protected balance:   number = 0;      // accessible in this class and subclasses
    private  #txHistory: string[] = [];   // JS private field — truly private at runtime

    constructor(owner: string) {
        this.id    = crypto.randomUUID();
        this.owner = owner;
    }

    deposit(amount: number): void {
        this.balance += amount;
        this.#txHistory.push(`+${amount}`);
    }

    get currentBalance(): number { return this.balance; }
}

class SavingsAccount extends BankAccount {
    private interestRate: number;

    constructor(owner: string, rate: number) {
        super(owner);
        this.interestRate = rate;
    }

    applyInterest(): void {
        this.balance *= (1 + this.interestRate);  // protected — accessible here
    }
}

// Parameter shorthand — auto-creates and assigns properties
class Point {
    constructor(
        public readonly x: number,
        public readonly y: number,
    ) {}
}

Abstract classes and implements

// Abstract class — can't be instantiated directly, defines contract for subclasses
abstract class Animal {
    abstract speak(): string;   // subclasses must implement this

    move(distance: number) {
        console.log(`Moving ${distance}m`);
    }
}

class Dog extends Animal {
    speak() { return "Woof!"; }
}

// implements — a class can implement one or more interfaces
interface Serializable {
    serialize():   string;
    deserialize(s: string): this;
}

interface Loggable {
    log(): void;
}

class Config implements Serializable, Loggable {
    constructor(private data: Record<string, string>) {}
    serialize()   { return JSON.stringify(this.data); }
    deserialize(s: string) { this.data = JSON.parse(s); return this; }
    log()         { console.log(this.data); }
}

Modules and declaration files

import type

Using import type tells TypeScript (and bundlers) that the import is type-only and can be erased at compile time. This avoids accidental value imports and speeds up builds.

// Imports erased at runtime — only used for type checking
import type { User, Config } from "./types";

// Mixing value and type imports in one statement
import { fetchUser, type FetchOptions } from "./api";

Declaration files (.d.ts)

Declaration files describe the shape of existing JavaScript code without providing an implementation. They let TypeScript understand libraries that were not written in TypeScript.

// math-utils.d.ts — describes math-utils.js
declare function add(a: number, b: number): number;
declare function subtract(a: number, b: number): number;
declare const PI: number;

// Ambient module declaration — for libraries without types
declare module "legacy-lib" {
    export function doSomething(input: string): void;
}

// Most popular libraries ship @types packages from DefinitelyTyped
// npm install --save-dev @types/node
// npm install --save-dev @types/react

Augmenting third-party types

Module augmentation lets you add to an existing module's types without modifying the original package — useful for extending library types with your own additions.

// Extend Express Request to add custom properties
import "express";

declare module "express" {
    interface Request {
        user?: { id: string; role: string };
    }
}

// Now req.user is typed everywhere
app.get("/profile", (req, res) => {
    const userId = req.user?.id;  // string | undefined
});