TypeScript: The Complete Guide for 2026

Published February 11, 2026 · 30 min read

TypeScript has become the default choice for serious JavaScript development. It adds a powerful static type system on top of JavaScript, catching bugs at compile time, enabling better tooling, and making large codebases maintainable. This comprehensive guide covers everything from basic type annotations to advanced patterns like conditional types, discriminated unions, and decorators — with practical code examples you can use immediately in your projects.

⚙ Try it: Use our JavaScript Runner to test code snippets, or check out the JSON Formatter for working with TypeScript API responses.

1. What Is TypeScript?

TypeScript is a statically typed superset of JavaScript created by Microsoft and first released in 2012. Every valid JavaScript program is also a valid TypeScript program, but TypeScript adds optional static types, interfaces, generics, and other features that enable better developer tooling and earlier bug detection.

TypeScript compiles (or more accurately, transpiles) to plain JavaScript. The type annotations are erased during compilation — they exist only at development time to help you write correct code. The output is standard JavaScript that runs anywhere: browsers, Node.js, Deno, Bun, or any other JavaScript runtime.

Why TypeScript over JavaScript?

The practical benefits of TypeScript become clear as projects grow:

Catch bugs before runtime: TypeScript detects type mismatches, misspelled property names, missing function arguments, and null/undefined access at compile time instead of waiting for runtime crashes.

Superior editor support: Type information powers autocompletion, inline documentation, refactoring tools, and jump-to-definition. Your editor understands your code deeply because TypeScript tells it the shape of every value.

Self-documenting code: Type annotations serve as living documentation. When you see function getUser(id: string): Promise<User>, you know exactly what the function expects and returns without reading the implementation.

Safe refactoring: Renaming a property, changing a function signature, or restructuring data causes the compiler to flag every location that needs updating. This makes large-scale refactoring feasible rather than terrifying.

Setting Up TypeScript

Install TypeScript globally or as a project dependency:

# Install globally
npm install -g typescript

# Or as a dev dependency (recommended)
npm install --save-dev typescript

# Initialize a tsconfig.json
npx tsc --init

# Compile a file
npx tsc myfile.ts

# Watch mode - recompile on changes
npx tsc --watch

2. Type Annotations and Basic Types

TypeScript provides type annotations using a colon syntax after variable names, function parameters, and return types. The compiler also infers types automatically when you assign values, so you don't always need explicit annotations.

Primitive Types

// Explicit type annotations
let username: string = "alice";
let age: number = 30;
let isActive: boolean = true;
let data: null = null;
let value: undefined = undefined;

// Type inference - TypeScript figures out the type
let city = "Berlin";       // inferred as string
let count = 42;            // inferred as number
let done = false;          // inferred as boolean

// BigInt and Symbol
let big: bigint = 100n;
let id: symbol = Symbol("id");

Arrays and Tuples

// Array types - two equivalent syntaxes
let numbers: number[] = [1, 2, 3];
let names: Array<string> = ["Alice", "Bob"];

// Tuples - fixed-length arrays with specific types per position
let point: [number, number] = [10, 20];
let entry: [string, number] = ["age", 30];

// Labeled tuples (TypeScript 4.0+)
type Range = [start: number, end: number];
let range: Range = [0, 100];

// Readonly arrays and tuples
let frozen: readonly number[] = [1, 2, 3];
// frozen.push(4); // Error: Property 'push' does not exist

Object Types

// Inline object type
let user: { name: string; age: number; email?: string } = {
    name: "Alice",
    age: 30
    // email is optional, so omitting it is fine
};

// Function types
let greet: (name: string) => string;
greet = (name) => `Hello, ${name}!`;

// The 'any' type - opts out of type checking (avoid when possible)
let flexible: any = "hello";
flexible = 42;       // no error
flexible.foo.bar;    // no error - dangerous!

// The 'unknown' type - safer alternative to 'any'
let input: unknown = getUserInput();
// input.trim();     // Error: Object is of type 'unknown'
if (typeof input === "string") {
    input.trim();    // OK after type check
}

// The 'never' type - represents values that never occur
function throwError(msg: string): never {
    throw new Error(msg);
}

// The 'void' type - functions that don't return a value
function logMessage(msg: string): void {
    console.log(msg);
}

3. Interfaces vs Type Aliases

TypeScript provides two ways to name object shapes: interface and type. They overlap significantly, but each has unique capabilities that make it better suited for specific use cases.

Interfaces

interface User {
    id: number;
    name: string;
    email: string;
    createdAt: Date;
}

// Extending interfaces
interface Admin extends User {
    role: "admin" | "superadmin";
    permissions: string[];
}

// Implementing interfaces in classes
class UserAccount implements User {
    constructor(
        public id: number,
        public name: string,
        public email: string,
        public createdAt: Date
    ) {}
}

// Declaration merging - unique to interfaces
interface Config {
    apiUrl: string;
}
interface Config {
    timeout: number;
}
// Config now has both apiUrl AND timeout
const config: Config = { apiUrl: "https://api.example.com", timeout: 5000 };

Type Aliases

type User = {
    id: number;
    name: string;
    email: string;
    createdAt: Date;
};

// Intersection types (similar to extends)
type Admin = User & {
    role: "admin" | "superadmin";
    permissions: string[];
};

// Types can represent things interfaces cannot
type ID = string | number;                    // union
type Pair = [string, number];                 // tuple
type StringMap = Record<string, string>;       // mapped type
type Callback = (data: string) => void;       // function type

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

When to Use Which

Use interfaces when defining object shapes for public APIs, library types, or class contracts. Their declaration merging capability is essential for extending third-party types.

Use type aliases when you need unions, intersections, mapped types, conditional types, or tuple types. They are more versatile for complex type transformations.

In practice, many teams adopt a simple rule: use interface for objects, type for everything else. Either way, be consistent within your codebase.

4. Generics

Generics are one of TypeScript's most powerful features. They let you write functions, classes, and interfaces that work with any type while preserving type safety. Instead of using any and losing type information, generics parameterize the type so it flows through your code.

Generic Functions

// Without generics - loses type information
function firstElement(arr: any[]): any {
    return arr[0];
}
const x = firstElement([1, 2, 3]); // x is 'any' - not useful

// With generics - preserves type information
function firstElement<T>(arr: T[]): T | undefined {
    return arr[0];
}
const a = firstElement([1, 2, 3]);       // a is number
const b = firstElement(["hello"]);        // b is string
const c = firstElement<boolean>([true]);  // explicit: c is boolean

Generic Constraints

// Constrain T to types that have a 'length' property
function logLength<T extends { length: number }>(item: T): T {
    console.log(item.length);
    return item;
}

logLength("hello");       // OK: string has length
logLength([1, 2, 3]);     // OK: array has length
// logLength(42);          // Error: number has no length

// Using keyof constraint
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
    return obj[key];
}

const user = { name: "Alice", age: 30 };
const name = getProperty(user, "name");  // type is string
const age = getProperty(user, "age");    // type is number
// getProperty(user, "email");            // Error: "email" not in keyof User

Generic Interfaces and Classes

// Generic interface for API responses
interface ApiResponse<T> {
    data: T;
    status: number;
    message: string;
    timestamp: Date;
}

type UserResponse = ApiResponse<User>;
type UsersResponse = ApiResponse<User[]>;

// Generic class
class Stack<T> {
    private items: T[] = [];

    push(item: T): void {
        this.items.push(item);
    }

    pop(): T | undefined {
        return this.items.pop();
    }

    peek(): T | undefined {
        return this.items[this.items.length - 1];
    }

    get size(): number {
        return this.items.length;
    }
}

const numberStack = new Stack<number>();
numberStack.push(1);
numberStack.push(2);
const top = numberStack.pop(); // type is number | undefined

Multiple Type Parameters

// Map one type to another
function mapArray<T, U>(arr: T[], fn: (item: T) => U): U[] {
    return arr.map(fn);
}

const lengths = mapArray(["hello", "world"], (s) => s.length);
// lengths is number[]

// Generic with default type parameter
interface PaginatedResult<T, M = Record<string, unknown>> {
    items: T[];
    total: number;
    page: number;
    meta: M;
}

// M defaults to Record<string, unknown> if not specified
type UserPage = PaginatedResult<User>;
type UserPageWithCursor = PaginatedResult<User, { cursor: string }>;

5. Enums

Enums define a set of named constants. TypeScript supports numeric enums, string enums, and heterogeneous enums. However, many TypeScript developers now prefer union types or as const objects over enums for various reasons.

Numeric and String Enums

// Numeric enum (auto-incremented from 0)
enum Direction {
    Up,      // 0
    Down,    // 1
    Left,    // 2
    Right    // 3
}

// String enum (explicit values required)
enum Status {
    Pending = "PENDING",
    Active = "ACTIVE",
    Inactive = "INACTIVE",
    Archived = "ARCHIVED"
}

function setStatus(status: Status): void {
    console.log(`Setting status to: ${status}`);
}

setStatus(Status.Active);   // "Setting status to: ACTIVE"
// setStatus("ACTIVE");      // Error: not assignable to Status

Const Enums and Alternatives

// Const enums are fully inlined at compile time (smaller output)
const enum HttpMethod {
    GET = "GET",
    POST = "POST",
    PUT = "PUT",
    DELETE = "DELETE"
}
// Compiled JS: just the string literal, no enum object

// Modern alternative: union types + const objects
const STATUS = {
    Pending: "PENDING",
    Active: "ACTIVE",
    Inactive: "INACTIVE",
} as const;

type Status = typeof STATUS[keyof typeof STATUS];
// Status = "PENDING" | "ACTIVE" | "INACTIVE"

// This approach:
// - No runtime overhead beyond a plain object
// - Works with Object.values() and Object.keys()
// - No surprises with numeric reverse mappings
// - Tree-shakeable

6. Utility Types

TypeScript ships with built-in utility types that transform existing types into new ones. These eliminate boilerplate and express common type patterns concisely.

Partial, Required, and Readonly

interface User {
    id: number;
    name: string;
    email: string;
    avatar: string;
}

// Partial<T> - all properties become optional
type UpdateUser = Partial<User>;
// { id?: number; name?: string; email?: string; avatar?: string }

function updateUser(id: number, updates: Partial<User>): void {
    // Only pass the fields you want to change
}
updateUser(1, { name: "New Name" }); // OK

// Required<T> - all properties become required
interface Config {
    apiUrl?: string;
    timeout?: number;
    retries?: number;
}
type FullConfig = Required<Config>;
// { apiUrl: string; timeout: number; retries: number }

// Readonly<T> - all properties become read-only
type FrozenUser = Readonly<User>;
const user: FrozenUser = { id: 1, name: "Alice", email: "a@b.com", avatar: "" };
// user.name = "Bob"; // Error: Cannot assign to 'name' because it is read-only

Pick and Omit

// Pick<T, K> - select specific properties
type UserPreview = Pick<User, "id" | "name" | "avatar">;
// { id: number; name: string; avatar: string }

// Omit<T, K> - remove specific properties
type UserWithoutAvatar = Omit<User, "avatar">;
// { id: number; name: string; email: string }

// Combine them for real-world patterns
type CreateUserInput = Omit<User, "id">;          // id is auto-generated
type PublicUser = Omit<User, "email">;              // hide sensitive data
type UserCard = Pick<User, "name" | "avatar">;      // display component

Record, Extract, Exclude, and ReturnType

// Record<K, V> - object type with keys K and values V
type PageRoutes = Record<string, React.ComponentType>;
type RolePermissions = Record<"admin" | "editor" | "viewer", string[]>;

const permissions: RolePermissions = {
    admin: ["read", "write", "delete"],
    editor: ["read", "write"],
    viewer: ["read"]
};

// Extract<T, U> - extract types from union that are assignable to U
type AllEvents = "click" | "scroll" | "mousemove" | "keypress" | "keydown";
type KeyEvents = Extract<AllEvents, "keypress" | "keydown">;
// "keypress" | "keydown"

// Exclude<T, U> - remove types from union that are assignable to U
type MouseEvents = Exclude<AllEvents, "keypress" | "keydown">;
// "click" | "scroll" | "mousemove"

// ReturnType<T> - extract a function's return type
function createUser() {
    return { id: 1, name: "Alice", role: "admin" as const };
}
type CreatedUser = ReturnType<typeof createUser>;
// { id: number; name: string; role: "admin" }

// Parameters<T> - extract a function's parameter types as a tuple
type CreateUserParams = Parameters<typeof createUser>;
// []

function fetchData(url: string, options: RequestInit): Promise<Response> {
    return fetch(url, options);
}
type FetchParams = Parameters<typeof fetchData>;
// [string, RequestInit]

NonNullable and Awaited

// NonNullable<T> - remove null and undefined from T
type MaybeString = string | null | undefined;
type DefiniteString = NonNullable<MaybeString>;  // string

// Awaited<T> - unwrap Promise types (TypeScript 4.5+)
type PromiseResult = Awaited<Promise<string>>;            // string
type NestedPromise = Awaited<Promise<Promise<number>>>;   // number

7. Union and Intersection Types

Union and intersection types are foundational to TypeScript's type system. They compose types together in two opposite ways: unions represent "either/or" while intersections represent "both/and".

Union Types

// A value can be one of several types
type ID = string | number;

function printId(id: ID): void {
    if (typeof id === "string") {
        console.log(id.toUpperCase());   // narrowed to string
    } else {
        console.log(id.toFixed(2));      // narrowed to number
    }
}

// String literal unions - powerful pattern for fixed values
type Theme = "light" | "dark" | "system";
type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
type LogLevel = "debug" | "info" | "warn" | "error";

function setTheme(theme: Theme): void {
    document.documentElement.dataset.theme = theme;
}
setTheme("dark");     // OK
// setTheme("blue");  // Error: not assignable to Theme

// Nullable types are unions with null/undefined
type MaybeUser = User | null;
type OptionalName = string | undefined;

Intersection Types

// Combine multiple types into one
type Timestamped = {
    createdAt: Date;
    updatedAt: Date;
};

type SoftDeletable = {
    deletedAt: Date | null;
    isDeleted: boolean;
};

// An entity that has all properties from both types
type User = {
    id: number;
    name: string;
    email: string;
};

type FullUser = User & Timestamped & SoftDeletable;
// FullUser has: id, name, email, createdAt, updatedAt, deletedAt, isDeleted

// Practical use: adding metadata to any type
type WithId<T> = T & { id: number };
type WithTimestamps<T> = T & Timestamped;

type UserRecord = WithId<WithTimestamps<{ name: string; email: string }>>;

8. Type Guards and Narrowing

Type narrowing is how TypeScript refines a broad type to a more specific one within a code block. Type guards are expressions that perform runtime checks and tell the compiler which type a value is.

Built-in Type Guards

function processValue(value: string | number | boolean): string {
    // typeof guard
    if (typeof value === "string") {
        return value.toUpperCase();      // string
    }
    if (typeof value === "number") {
        return value.toFixed(2);         // number
    }
    return value ? "yes" : "no";         // boolean
}

// instanceof guard
function formatError(error: unknown): string {
    if (error instanceof Error) {
        return error.message;             // Error
    }
    if (typeof error === "string") {
        return error;                     // string
    }
    return "Unknown error";
}

// 'in' operator guard
interface Cat { meow(): void; whiskers: number; }
interface Dog { bark(): void; breed: string; }

function interact(animal: Cat | Dog): void {
    if ("meow" in animal) {
        animal.meow();       // Cat
    } else {
        animal.bark();       // Dog
    }
}

// Truthiness narrowing
function printName(name: string | null | undefined): void {
    if (name) {
        console.log(name.toUpperCase());  // string (null and undefined excluded)
    }
}

User-Defined Type Guards

// Custom type predicate with 'is' keyword
interface Fish { swim(): void; }
interface Bird { fly(): void; }

function isFish(animal: Fish | Bird): animal is Fish {
    return (animal as Fish).swim !== undefined;
}

function move(animal: Fish | Bird): void {
    if (isFish(animal)) {
        animal.swim();    // TypeScript knows it's Fish
    } else {
        animal.fly();     // TypeScript knows it's Bird
    }
}

// Assertion function (TypeScript 3.7+)
function assertIsString(val: unknown): asserts val is string {
    if (typeof val !== "string") {
        throw new Error(`Expected string, got ${typeof val}`);
    }
}

function processInput(input: unknown): void {
    assertIsString(input);
    // After assertion, TypeScript knows input is string
    console.log(input.toUpperCase());
}

9. Discriminated Unions

Discriminated unions (also called tagged unions) are a pattern where each member of a union has a common property (the "discriminant") with a unique literal type. This lets TypeScript narrow the type precisely based on that property.

// Each shape has a 'kind' discriminant
interface Circle {
    kind: "circle";
    radius: number;
}

interface Rectangle {
    kind: "rectangle";
    width: number;
    height: number;
}

interface Triangle {
    kind: "triangle";
    base: number;
    height: number;
}

type Shape = Circle | Rectangle | Triangle;

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 (shape.base * shape.height) / 2;
    }
}

// Exhaustiveness checking with 'never'
function assertNever(x: never): never {
    throw new Error(`Unexpected value: ${x}`);
}

function describeShape(shape: Shape): string {
    switch (shape.kind) {
        case "circle":
            return `Circle with radius ${shape.radius}`;
        case "rectangle":
            return `${shape.width}x${shape.height} rectangle`;
        case "triangle":
            return `Triangle with base ${shape.base}`;
        default:
            // If you add a new Shape but forget to handle it here,
            // TypeScript will report an error on this line
            return assertNever(shape);
    }
}

Real-World: API Response Handling

type ApiResult<T> =
    | { status: "success"; data: T }
    | { status: "error"; error: string; code: number }
    | { status: "loading" };

function renderResult<T>(result: ApiResult<T>): string {
    switch (result.status) {
        case "loading":
            return "Loading...";
        case "error":
            return `Error ${result.code}: ${result.error}`;
        case "success":
            return JSON.stringify(result.data);
    }
}

// Redux-style action handling
type Action =
    | { type: "INCREMENT"; amount: number }
    | { type: "DECREMENT"; amount: number }
    | { type: "RESET" };

function reducer(state: number, action: Action): number {
    switch (action.type) {
        case "INCREMENT":
            return state + action.amount;
        case "DECREMENT":
            return state - action.amount;
        case "RESET":
            return 0;
    }
}

10. Template Literal Types

Template literal types (introduced in TypeScript 4.1) let you build string types using template literal syntax. Combined with union types, they can generate large sets of string literals automatically.

// Basic template literal type
type Greeting = `Hello, ${string}`;
let g: Greeting = "Hello, world";     // OK
// let g2: Greeting = "Hi, world";    // Error

// Combining unions to generate all permutations
type Color = "red" | "green" | "blue";
type Size = "small" | "medium" | "large";
type ColorSize = `${Color}-${Size}`;
// "red-small" | "red-medium" | "red-large" | "green-small" | ...

// CSS property patterns
type CSSUnit = "px" | "rem" | "em" | "%";
type CSSValue = `${number}${CSSUnit}`;
let padding: CSSValue = "16px";     // OK
let margin: CSSValue = "1.5rem";    // OK

// Event handler names
type DomEvent = "click" | "focus" | "blur" | "input";
type EventHandler = `on${Capitalize<DomEvent>}`;
// "onClick" | "onFocus" | "onBlur" | "onInput"

// Intrinsic string manipulation types
type Upper = Uppercase<"hello">;       // "HELLO"
type Lower = Lowercase<"HELLO">;       // "hello"
type Cap = Capitalize<"hello">;         // "Hello"
type Uncap = Uncapitalize<"Hello">;     // "hello"

// Real-world: type-safe event emitter
type EventMap = {
    userCreated: { userId: string };
    orderPlaced: { orderId: string; total: number };
    pageViewed: { path: string };
};

class TypedEmitter<T extends Record<string, unknown>> {
    private handlers = new Map<string, Function[]>();

    on<K extends keyof T & string>(event: K, handler: (payload: T[K]) => void): void {
        const list = this.handlers.get(event) || [];
        list.push(handler);
        this.handlers.set(event, list);
    }

    emit<K extends keyof T & string>(event: K, payload: T[K]): void {
        this.handlers.get(event)?.forEach((fn) => fn(payload));
    }
}

const emitter = new TypedEmitter<EventMap>();
emitter.on("userCreated", (e) => console.log(e.userId));   // fully typed
emitter.on("orderPlaced", (e) => console.log(e.total));    // fully typed

11. Decorators

Decorators are a way to annotate and modify classes, methods, properties, and parameters at design time. TypeScript 5.0 introduced Stage 3 TC39 decorators that align with the JavaScript standard, alongside the older experimental decorators.

// TC39 Stage 3 decorators (TypeScript 5.0+)
// No experimentalDecorators flag needed

function logged(
    originalMethod: Function,
    context: ClassMethodDecoratorContext
) {
    const methodName = String(context.name);
    function replacementMethod(this: any, ...args: any[]) {
        console.log(`Calling ${methodName} with`, args);
        const result = originalMethod.call(this, ...args);
        console.log(`${methodName} returned`, result);
        return result;
    }
    return replacementMethod;
}

class Calculator {
    @logged
    add(a: number, b: number): number {
        return a + b;
    }

    @logged
    multiply(a: number, b: number): number {
        return a * b;
    }
}

const calc = new Calculator();
calc.add(2, 3);
// Calling add with [2, 3]
// add returned 5

// Class decorator
function sealed(constructor: Function) {
    Object.seal(constructor);
    Object.seal(constructor.prototype);
}

@sealed
class Greeter {
    greeting: string;
    constructor(message: string) {
        this.greeting = message;
    }
    greet() {
        return `Hello, ${this.greeting}`;
    }
}

Decorators are widely used in frameworks like Angular, NestJS, and TypeORM for dependency injection, route definitions, entity mapping, and validation.

12. Module System

TypeScript uses the standard ES module syntax (import/export) and adds type-only imports for cases where you only need types at compile time.

// Named exports
export interface User {
    id: number;
    name: string;
}

export function createUser(name: string): User {
    return { id: Date.now(), name };
}

export const MAX_USERS = 1000;

// Default export
export default class UserService {
    getAll(): User[] { /* ... */ return []; }
}

// Importing
import UserService, { User, createUser, MAX_USERS } from "./user-service";

// Type-only imports (erased at compile time, zero runtime cost)
import type { User } from "./user-service";
import { type User, createUser } from "./user-service"; // inline syntax

// Re-exporting
export { User } from "./user-service";
export type { User } from "./user-service";  // type-only re-export

// Namespace imports
import * as UserModule from "./user-service";
const user: UserModule.User = UserModule.createUser("Alice");

Type-only imports are important because they guarantee that the import is erased during compilation. This prevents unintended side effects from module loading and can improve bundle size with tree-shaking.

13. tsconfig.json Configuration

The tsconfig.json file controls how TypeScript compiles your project. Here is a well-commented configuration suitable for most modern projects:

{
    "compilerOptions": {
        // Language and Environment
        "target": "ES2022",              // Output JS version
        "lib": ["ES2022", "DOM", "DOM.Iterable"],
        "module": "ESNext",              // Module system for output
        "moduleResolution": "bundler",   // How imports are resolved

        // Strictness
        "strict": true,                  // Enable all strict checks
        "noUncheckedIndexedAccess": true, // arr[0] is T | undefined
        "noImplicitOverride": true,      // Require 'override' keyword

        // Output
        "outDir": "./dist",
        "declaration": true,             // Generate .d.ts files
        "declarationMap": true,          // Source maps for .d.ts
        "sourceMap": true,               // Generate source maps

        // Interop
        "esModuleInterop": true,         // CommonJS/ESM interop
        "allowSyntheticDefaultImports": true,
        "forceConsistentCasingInFileNames": true,
        "isolatedModules": true,         // Required for Babel/SWC/esbuild

        // Type Checking
        "skipLibCheck": true,            // Skip checking node_modules .d.ts
        "resolveJsonModule": true        // Allow importing .json files
    },
    "include": ["src/**/*"],
    "exclude": ["node_modules", "dist", "**/*.test.ts"]
}

Key Options Explained

target determines which JavaScript version the output should be compatible with. ES2022 is a good default for modern environments. It affects which syntax features are downleveled and which built-in type definitions are available.

moduleResolution: "bundler" (TypeScript 5.0+) tells TypeScript to resolve imports the same way modern bundlers like Vite, webpack, and esbuild do. This supports package.json exports fields and extensionless imports.

isolatedModules ensures each file can be independently transpiled, which is required when using Babel, SWC, or esbuild as your compiler instead of tsc.

noUncheckedIndexedAccess is one of the most valuable options not included in strict. It makes array indexing and object property access with dynamic keys return T | undefined instead of T, catching common off-by-one and missing-key bugs.

14. Strict Mode

The strict: true flag enables a family of stricter type-checking options that catch more bugs. Every new TypeScript project should start with strict: true. Here is what it enables:

// strict: true enables all of these:
{
    "strictNullChecks": true,        // null/undefined are separate types
    "noImplicitAny": true,           // Error on implicit 'any' types
    "strictFunctionTypes": true,     // Stricter function type checking
    "strictBindCallApply": true,     // Check bind, call, apply arguments
    "strictPropertyInitialization": true,  // Class properties must be initialized
    "noImplicitThis": true,          // Error on 'this' with implicit 'any'
    "alwaysStrict": true,            // Emit "use strict" in output
    "useUnknownInCatchVariables": true // catch(e) types e as 'unknown'
}

strictNullChecks in Practice

// With strictNullChecks: true
function getUser(id: number): User | null {
    // might return null if user not found
    return db.find(id) || null;
}

const user = getUser(1);
// user.name;         // Error: Object is possibly 'null'
user?.name;           // OK: optional chaining
user!.name;           // OK but dangerous: non-null assertion

// Better: explicit null check
if (user) {
    user.name;        // OK: narrowed to User
}

// Nullish coalescing
const name = user?.name ?? "Anonymous";

Without strictNullChecks, null and undefined are assignable to every type, which means TypeScript cannot help you prevent null pointer errors — the single most common category of runtime bugs in JavaScript.

15. TypeScript with React

TypeScript and React work extremely well together. Type-safe props, state, context, and hooks catch mistakes at compile time that would otherwise surface as confusing runtime errors.

Component Props

// Function component with typed props
interface ButtonProps {
    label: string;
    variant?: "primary" | "secondary" | "danger";
    disabled?: boolean;
    onClick: () => void;
}

function Button({ label, variant = "primary", disabled = false, onClick }: ButtonProps) {
    return (
        <button
            className={`btn btn-${variant}`}
            disabled={disabled}
            onClick={onClick}
        >
            {label}
        </button>
    );
}

// Using the component - TypeScript validates all props
<Button label="Submit" onClick={() => handleSubmit()} />
// <Button label="Submit" />  // Error: onClick is required

// Props with children
interface CardProps {
    title: string;
    children: React.ReactNode;
}

function Card({ title, children }: CardProps) {
    return (
        <div className="card">
            <h2>{title}</h2>
            <div>{children}</div>
        </div>
    );
}

// Generic component
interface ListProps<T> {
    items: T[];
    renderItem: (item: T) => React.ReactNode;
    keyExtractor: (item: T) => string;
}

function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
    return (
        <ul>
            {items.map((item) => (
                <li key={keyExtractor(item)}>{renderItem(item)}</li>
            ))}
        </ul>
    );
}

Typed Hooks

// useState infers the type from the initial value
const [count, setCount] = useState(0);          // number
const [name, setName] = useState("");            // string

// Explicit type for complex or nullable state
const [user, setUser] = useState<User | null>(null);
const [items, setItems] = useState<Item[]>([]);

// useRef
const inputRef = useRef<HTMLInputElement>(null);
// inputRef.current?.focus();

// useReducer with discriminated union actions
interface State { count: number; error: string | null; }
type Action =
    | { type: "increment"; amount: number }
    | { type: "reset" }
    | { type: "setError"; message: string };

function reducer(state: State, action: Action): State {
    switch (action.type) {
        case "increment":
            return { ...state, count: state.count + action.amount };
        case "reset":
            return { count: 0, error: null };
        case "setError":
            return { ...state, error: action.message };
    }
}

const [state, dispatch] = useReducer(reducer, { count: 0, error: null });
dispatch({ type: "increment", amount: 5 }); // fully type-checked

// useContext with typed context
interface ThemeContext {
    theme: "light" | "dark";
    toggleTheme: () => void;
}

const ThemeCtx = createContext<ThemeContext | null>(null);

function useTheme(): ThemeContext {
    const ctx = useContext(ThemeCtx);
    if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
    return ctx;
}

Event Handlers

function Form() {
    const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
        console.log(e.target.value);
    };

    const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
        e.preventDefault();
        // process form
    };

    const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
        console.log(e.clientX, e.clientY);
    };

    return (
        <form onSubmit={handleSubmit}>
            <input onChange={handleChange} />
            <button onClick={handleClick}>Submit</button>
        </form>
    );
}

16. TypeScript with Node.js

Using TypeScript with Node.js requires installing type definitions and configuring your project for server-side compilation. Modern tools like tsx make this near-frictionless.

Project Setup

# Initialize project
npm init -y
npm install --save-dev typescript @types/node

# For direct execution without separate compile step
npm install --save-dev tsx

# tsconfig.json for Node.js
{
    "compilerOptions": {
        "target": "ES2022",
        "module": "NodeNext",
        "moduleResolution": "NodeNext",
        "outDir": "./dist",
        "rootDir": "./src",
        "strict": true,
        "esModuleInterop": true,
        "declaration": true,
        "sourceMap": true
    },
    "include": ["src/**/*"]
}

Express Server Example

import express, { Request, Response, NextFunction } from "express";

// Typed request with body and params
interface CreateUserBody {
    name: string;
    email: string;
}

interface UserParams {
    id: string;
}

const app = express();
app.use(express.json());

// Typed route handler
app.post(
    "/users",
    (req: Request<{}, {}, CreateUserBody>, res: Response) => {
        const { name, email } = req.body; // fully typed
        // create user...
        res.status(201).json({ id: "1", name, email });
    }
);

app.get(
    "/users/:id",
    (req: Request<UserParams>, res: Response) => {
        const { id } = req.params; // string
        // fetch user...
        res.json({ id, name: "Alice" });
    }
);

// Typed error handler
interface AppError extends Error {
    statusCode: number;
}

app.use((err: AppError, _req: Request, res: Response, _next: NextFunction) => {
    res.status(err.statusCode || 500).json({ error: err.message });
});

app.listen(3000, () => console.log("Server running on port 3000"));

File System and Async Operations

import { readFile, writeFile } from "fs/promises";
import path from "path";

interface Config {
    port: number;
    database: {
        host: string;
        port: number;
        name: string;
    };
}

async function loadConfig(filePath: string): Promise<Config> {
    const absolute = path.resolve(filePath);
    const raw = await readFile(absolute, "utf-8");
    const parsed: unknown = JSON.parse(raw);

    // Validate at runtime since JSON.parse returns 'any'
    if (!isValidConfig(parsed)) {
        throw new Error("Invalid configuration file");
    }
    return parsed;
}

function isValidConfig(data: unknown): data is Config {
    if (typeof data !== "object" || data === null) return false;
    const obj = data as Record<string, unknown>;
    return (
        typeof obj.port === "number" &&
        typeof obj.database === "object" &&
        obj.database !== null
    );
}

17. Migrating from JavaScript to TypeScript

Migrating an existing JavaScript project to TypeScript is best done incrementally. Attempting a full rewrite is risky and unnecessary — TypeScript is designed for gradual adoption.

Step-by-Step Migration Strategy

  1. Add TypeScript to your project without changing any existing code:
    npm install --save-dev typescript
    npx tsc --init
  2. Configure tsconfig.json for coexistence:
    {
        "compilerOptions": {
            "allowJs": true,           // Accept .js files
            "checkJs": false,          // Don't type-check .js files yet
            "strict": false,           // Start lenient
            "outDir": "./dist",
            "rootDir": "./src",
            "target": "ES2022",
            "module": "ESNext",
            "moduleResolution": "bundler",
            "esModuleInterop": true,
            "skipLibCheck": true
        },
        "include": ["src/**/*"]
    }
  3. Install type definitions for your dependencies:
    # Check which types are available
    npm install --save-dev @types/node @types/express @types/lodash
    # Many modern packages ship their own types (no @types needed)
  4. Rename files one at a time from .js to .ts (or .jsx to .tsx). Start with leaf modules that have few imports and work inward.
  5. Add types to function signatures first. Parameters and return types give you the most safety for the least effort:
    // Before (JavaScript)
    function calculateTotal(items, taxRate) {
        return items.reduce((sum, item) => sum + item.price * item.quantity, 0) * (1 + taxRate);
    }
    
    // After (TypeScript)
    interface LineItem {
        name: string;
        price: number;
        quantity: number;
    }
    
    function calculateTotal(items: LineItem[], taxRate: number): number {
        return items.reduce((sum, item) => sum + item.price * item.quantity, 0) * (1 + taxRate);
    }
  6. Enable strict options gradually. Turn on one option at a time, fix the errors, then move to the next:
    // Recommended order:
    // 1. noImplicitAny        - find untyped parameters
    // 2. strictNullChecks     - catch null/undefined bugs
    // 3. strictFunctionTypes  - stricter callback types
    // 4. strict: true         - all remaining strict options

JSDoc Types as a Bridge

If renaming files is too disruptive, you can add types to JavaScript files using JSDoc comments. With checkJs: true, TypeScript reads these annotations:

// works-in-plain-js.js

/**
 * @param {string} name
 * @param {number} age
 * @returns {{ name: string, age: number, id: string }}
 */
function createUser(name, age) {
    return { name, age, id: crypto.randomUUID() };
}

/** @type {import('./types').Config} */
const config = loadConfig();

This approach lets you add type safety without changing file extensions, build pipelines, or import statements. It is especially useful in large legacy codebases where renaming hundreds of files would create enormous pull requests.

Frequently Asked Questions

What is TypeScript and how is it different from JavaScript?

TypeScript is a statically typed superset of JavaScript developed by Microsoft. It adds optional type annotations, interfaces, generics, and other features on top of standard JavaScript. All valid JavaScript is valid TypeScript, but TypeScript code must be compiled to JavaScript before it can run in browsers or Node.js. The key difference is that TypeScript catches type errors at compile time rather than at runtime, preventing entire categories of bugs before your code ever executes.

Should I use interfaces or type aliases in TypeScript?

Both interfaces and type aliases can describe object shapes, but they have different strengths. Interfaces support declaration merging (adding new fields to an existing interface across files), can be extended with the extends keyword, and produce clearer error messages. Type aliases can represent unions, intersections, tuples, mapped types, and conditional types that interfaces cannot. The general recommendation is to use interfaces for object shapes and public API contracts, and type aliases for unions, intersections, and complex type transformations.

What are TypeScript generics and when should I use them?

Generics allow you to write reusable functions, classes, and interfaces that work with multiple types while preserving type safety. Instead of using any and losing type information, generics let you parameterize types. Use generics when a function or class needs to work with different types but maintain the relationship between input and output types. Common examples include array utility functions, API response wrappers, data structure implementations, and React component props that accept variable content types.

What TypeScript utility types should every developer know?

The most essential TypeScript utility types are: Partial<T> makes all properties optional, Required<T> makes all properties required, Pick<T, K> selects specific properties, Omit<T, K> removes specific properties, Record<K, V> creates an object type with keys K and values V, Readonly<T> makes all properties read-only, ReturnType<T> extracts a function's return type, and Parameters<T> extracts a function's parameter types as a tuple. These utility types eliminate boilerplate and make type transformations concise and readable.

How do I migrate an existing JavaScript project to TypeScript?

Migrate incrementally rather than all at once. Start by adding a tsconfig.json with allowJs: true and strict: false so TypeScript and JavaScript files coexist. Rename files from .js to .ts one at a time, starting with utility modules that have few dependencies. Add type annotations to function parameters and return types first. Install @types packages for your dependencies. Gradually enable stricter compiler options (noImplicitAny, strictNullChecks) as you add more types. This approach lets you adopt TypeScript progressively without blocking feature development.

Related Resources

Keep learning: TypeScript builds on JavaScript fundamentals. Review our ES6+ Features Guide for the language foundations, and explore TypeScript Tips and Tricks for more advanced patterns.

Related Resources

TypeScript Tips and Tricks
15 practical patterns for cleaner TypeScript
JavaScript ES6+ Features Guide
Modern JavaScript fundamentals for TypeScript
React Hooks Complete Guide
Type-safe React with Hooks and TypeScript
REST API Design Guide
Design APIs with TypeScript-friendly contracts
JSON Formatter
Format and validate JSON for TypeScript APIs