Episode 1 — Fundamentals / 1.25 — TypeScript Essentials

1.25.e — Interfaces vs Type Aliases

In one sentence: Interfaces describe the shape of objects and can be extended or merged, while type aliases can represent any type — unions, primitives, tuples, and complex compositions — making each suited to different situations.

Navigation: ← 1.25 Overview · 1.25.f — Union and Intersection Types →


1. interface — describing object shapes

An interface declares the structure an object must have:

interface User {
  id: number;
  name: string;
  email: string;
  isActive: boolean;
}

// An object must match ALL properties:
const alice: User = {
  id: 1,
  name: "Alice",
  email: "alice@example.com",
  isActive: true,
};

// Missing property → error:
// const bob: User = { id: 2, name: "Bob" };
// Error: Type '...' is missing the following properties: email, isActive

Interfaces with methods

interface Calculator {
  add(a: number, b: number): number;
  subtract(a: number, b: number): number;
  // Alternative syntax:
  multiply: (a: number, b: number) => number;
}

const calc: Calculator = {
  add: (a, b) => a + b,
  subtract: (a, b) => a - b,
  multiply: (a, b) => a * b,
};

2. type — creating type aliases

A type alias gives a name to any type expression:

// Object type (looks similar to interface)
type User = {
  id: number;
  name: string;
  email: string;
  isActive: boolean;
};

// But type aliases can also represent:
type ID = string | number;                          // Union
type Callback = (data: string) => void;             // Function
type Pair = [string, number];                       // Tuple
type Nullable<T> = T | null;                        // Generic alias
type Status = "pending" | "active" | "inactive";    // Literal union
type Coordinate = { x: number; y: number };         // Object

3. Interface extends — inheritance

Interfaces can extend other interfaces to inherit their properties:

interface Person {
  name: string;
  age: number;
}

interface Employee extends Person {
  employeeId: string;
  department: string;
}

// Employee must have ALL properties from Person + its own:
const emp: Employee = {
  name: "Alice",
  age: 30,
  employeeId: "E001",
  department: "Engineering",
};

// Multiple inheritance — extend several interfaces:
interface Timestamped {
  createdAt: Date;
  updatedAt: Date;
}

interface AuditedEmployee extends Employee, Timestamped {
  lastLogin: Date;
}

4. Type intersection — composing types

Type aliases use & (intersection) to combine types:

type Person = {
  name: string;
  age: number;
};

type ContactInfo = {
  email: string;
  phone: string;
};

// Intersection: must satisfy BOTH types
type Employee = Person & ContactInfo & {
  employeeId: string;
  department: string;
};

const emp: Employee = {
  name: "Alice",
  age: 30,
  email: "alice@example.com",
  phone: "555-0100",
  employeeId: "E001",
  department: "Engineering",
};

extends (interface) vs & (type) — comparison:

Featureinterface extendstype & (intersection)
Syntaxinterface B extends Atype B = A & { ... }
Multipleextends A, B, CA & B & C
Conflict handlingCompile error on incompatible propertiesCreates never for conflicting properties
PerformanceSlightly better (cached by compiler)Computed on each use

5. Declaration merging — interfaces can be re-opened

Interfaces with the same name in the same scope are automatically merged:

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

// Later in the same scope (or another file):
interface User {
  email: string;
}

// Now User has ALL properties:
const user: User = {
  id: 1,
  name: "Alice",
  email: "alice@example.com",  // Required — from second declaration
};

When is this useful?

  • Extending third-party types — add properties to library interfaces without modifying their source
  • Module augmentation — extend Express Request, Window, etc.
// Example: adding a custom property to Express Request
declare module "express" {
  interface Request {
    userId?: string;
  }
}

Type aliases CANNOT be re-opened:

type User = { id: number; name: string };
// type User = { email: string };  // Error: Duplicate identifier 'User'

6. Optional properties

Use ? to mark properties that may or may not exist:

interface Config {
  host: string;
  port: number;
  ssl?: boolean;         // Optional — may be undefined
  timeout?: number;      // Optional
}

// All valid:
const config1: Config = { host: "localhost", port: 3000 };
const config2: Config = { host: "localhost", port: 3000, ssl: true };
const config3: Config = { host: "localhost", port: 3000, ssl: true, timeout: 5000 };

// When accessing optional properties, TypeScript knows they might be undefined:
function getTimeout(config: Config): number {
  // config.timeout is 'number | undefined'
  return config.timeout ?? 3000;  // Default to 3000 if undefined
}

7. Readonly properties

Use readonly to prevent reassignment after creation:

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

const origin: Point = { x: 0, y: 0 };
// origin.x = 5;  // Error: Cannot assign to 'x' because it is a read-only property

// Readonly is shallow — nested objects can still be mutated:
interface Config {
  readonly settings: {
    theme: string;
  };
}

const config: Config = { settings: { theme: "dark" } };
// config.settings = { theme: "light" };  // Error — can't reassign 'settings'
config.settings.theme = "light";          // OK — inner object is NOT readonly

// For deep readonly, use Readonly<T> utility type or custom deep readonly

8. Index signatures

When you do not know all property names in advance but know their types:

interface StringMap {
  [key: string]: string;
}

const translations: StringMap = {
  hello: "hola",
  goodbye: "adios",
  thanks: "gracias",
  // Any string key → string value is valid
};

// Numeric index signature (for array-like objects)
interface NumberArray {
  [index: number]: string;
}

const arr: NumberArray = ["a", "b", "c"];

// Combining fixed and index signatures
interface UserMap {
  [userId: string]: User;
  // All values must be User
}

// Mixed: known + dynamic properties
interface Env {
  NODE_ENV: "development" | "production" | "test";
  PORT: string;
  [key: string]: string;  // Allow any additional env vars
}

9. implements — interfaces with classes

Classes can implement interfaces, ensuring they provide all required members:

interface Printable {
  print(): string;
}

interface Loggable {
  log(message: string): void;
}

class Report implements Printable, Loggable {
  constructor(private title: string) {}

  print(): string {
    return `Report: ${this.title}`;
  }

  log(message: string): void {
    console.log(`[Report] ${message}`);
  }
}

// The interface defines the CONTRACT — the class provides the IMPLEMENTATION
// Multiple classes can implement the same interface differently:
class Invoice implements Printable {
  constructor(private amount: number) {}

  print(): string {
    return `Invoice: $${this.amount.toFixed(2)}`;
  }
}

Note: type aliases cannot be used with implements (only interface).


10. When to use interface vs type

Use interface when:

  • Defining object shapes (the primary use case)
  • You want declaration merging (extending third-party types)
  • Defining class contracts (implements)
  • Building public APIs where consumers may extend your types

Use type when:

  • Creating union types: type ID = string | number
  • Creating tuple types: type Pair = [string, number]
  • Creating function types: type Handler = (e: Event) => void
  • Creating mapped or conditional types (advanced)
  • Aliasing primitives: type Seconds = number

Quick reference table

Capabilityinterfacetype
Object shapeYesYes
Extend/inheritextends& intersection
Declaration mergingYesNo
implements (classes)YesNo
Union typesNoYes
Tuple typesNoYes
Primitive aliasesNoYes
Function typesYes (verbose)Yes (clean)
Mapped typesNoYes
Computed propertiesNoYes

Practical guideline

Default to interface for object shapes. Use type for everything else.

This is not a strict rule — many teams use type for everything and that works fine too. Consistency within a project matters more than which you choose.


11. Real-world examples

API response types

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

interface PaginatedResponse<T> extends ApiResponse<T[]> {
  page: number;
  totalPages: number;
  totalItems: number;
}

interface User {
  id: number;
  name: string;
  email: string;
  role: "admin" | "user" | "guest";
}

// Usage:
async function fetchUsers(): Promise<PaginatedResponse<User>> {
  const response = await fetch("/api/users");
  return response.json();
}

const result = await fetchUsers();
result.data[0].name;       // string — fully typed!
result.totalPages;          // number

React component props

interface ButtonProps {
  label: string;
  onClick: () => void;
  variant?: "primary" | "secondary" | "danger";
  disabled?: boolean;
  size?: "small" | "medium" | "large";
  icon?: React.ReactNode;
}

// Extending base HTML attributes:
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
  label: string;
  error?: string;
}

Form state with types

type FormStatus = "idle" | "submitting" | "success" | "error";

interface FormState<T> {
  values: T;
  errors: Partial<Record<keyof T, string>>;
  status: FormStatus;
  isDirty: boolean;
}

interface LoginForm {
  email: string;
  password: string;
  rememberMe: boolean;
}

// Usage:
const loginState: FormState<LoginForm> = {
  values: { email: "", password: "", rememberMe: false },
  errors: {},
  status: "idle",
  isDirty: false,
};

Key takeaways

  1. Interfaces describe object shapes and support extends, declaration merging, and implements.
  2. Type aliases can represent any type — unions, tuples, primitives, functions, and complex compositions.
  3. Interfaces can be re-opened (declaration merging); type aliases cannot.
  4. Use ? for optional properties and readonly for immutable properties.
  5. Index signatures ([key: string]: Type) handle objects with dynamic keys.
  6. Default to interface for object shapes, type for everything else — but consistency matters more.

Explain-It Challenge

Explain without notes:

  1. What can a type alias do that an interface cannot?
  2. What is declaration merging, and when is it useful?
  3. What does readonly do, and why is it considered "shallow"?

Navigation: ← 1.25 Overview · 1.25.f — Union and Intersection Types →