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 variableCompilation 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)
// - noImplicitThisPrimitive 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 optionalEnums
// 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
| Type | Meaning | Use when |
|---|---|---|
any | Opts out of type checking entirely | Migrating JS, last resort only |
unknown | Like any but forces you to narrow before use | External data, API responses, catch clauses |
never | A value that can never exist | Exhaustive checks, functions that throw/loop forever |
void | Absence of a return value | Function return type when nothing is returned |
null | Intentional absence | With strictNullChecks on, must be handled explicitly |
undefined | Not yet assigned | Optional 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
| interface | type alias | |
|---|---|---|
| Object shapes | ✓ | ✓ |
| Primitives / unions / tuples | ✗ | ✓ |
| Extend | extends | & intersection |
| Declaration merging | ✓ (automatic) | ✗ |
| Use for | Public API shapes, library augmentation | Unions, 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, updatedAtLiteral 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 objectGeneric 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
| Utility | Description |
|---|---|
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 reassignmentFunction and inference utilities
| Utility | Description |
|---|---|
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 nullClasses
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/reactAugmenting 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
});