03 - Interfaces & Types

Interfaces

Interfaces define the shape of objects — what properties they must have and what types those properties are. They only exist at compile time and produce zero JavaScript output.

interface User {
  id: number;
  name: string;
  email?: string;           // optional — doesn't have to be provided
  readonly createdAt: Date;  // readonly — can't be changed after creation
}

const user: User = {
  id: 1,
  name: 'Alice',
  createdAt: new Date()
  // email is optional, so we can skip it
};

user.createdAt = new Date(); // ❌ error — readonly

Method Signatures in Interfaces

Interfaces can define what methods an object must have — but only the signature (name, parameters, return type). Never the implementation.

interface UserService {
  getUser(id: number): Promise<User>;       // ✅ signature only
  deleteUser(id: number): Promise<void>;    // ✅ signature only

  // getUser(id: number) { return fetch(...); }  // ❌ can't put code in an interface
}

// The interface just says "you must have these methods."
// How you implement them is up to you — a plain object works:
const service: UserService = {
  async getUser(id) { return fetch(`/api/users/${id}`).then(r => r.json()); },
  async deleteUser(id) { await fetch(`/api/users/${id}`, { method: 'DELETE' }); }
};

Extending Interfaces

An interface can inherit from another and add more properties:

interface Animal {
  name: string;
}

interface Dog extends Animal {
  breed: string;
}

// Dog requires both name (from Animal) and breed
const myDog: Dog = {
  name: 'Buddy',
  breed: 'Golden Retriever'
};

Declaration Merging

If you define the same interface name twice, TypeScript merges them. This is unique to interfaces — types can't do this.

interface Window {
  customProp: string;
}

interface Window {
  anotherProp: number;
}

// Window now has both customProp and anotherProp

This is mainly useful for extending third-party types (like adding properties to the global Window).


Type Aliases

type is a TypeScript keyword — types are checked at compile time and completely erased from the JavaScript output. They're more flexible than interfaces and can represent any type, not just objects.

type Status = 'active' | 'inactive' | 'pending';  // union of strings
type ID = string | number;                          // union of types

type Point = {
  x: number;
  y: number;
};

Function Types

Types can describe function signatures:

type Formatter = (input: string) => string;

const shout: Formatter = (input) => input.toUpperCase();
const whisper: Formatter = (input) => input.toLowerCase();

Extending Types with Intersection (&)

Types use & instead of extends to combine:

type A = { a: number };
type B = { b: string };
type C = A & B;  // has both a and b

const obj: C = { a: 1, b: 'hello' };

Union Types

A value that can be one of several types.

type ID = string | number;

function printId(id: ID) {
  if (typeof id === 'string') {
    console.log(id.toUpperCase());  // TypeScript knows it's a string here
  } else {
    console.log(id);                // TypeScript knows it's a number here
  }
}

Discriminated Unions

Add a shared type property to tell variants apart. TypeScript narrows the type automatically inside if checks:

interface Success {
  type: 'success';
  data: string;
}

interface Error {
  type: 'error';
  message: string;
}

type Result = Success | Error;

function handleResult(result: Result) {
  if (result.type === 'success') {
    console.log(result.data);     // ✅ TypeScript knows this is Success
  } else {
    console.log(result.message);  // ✅ TypeScript knows this is Error
  }
}

Here type is the discriminator — a shared property that acts like a label. When you have a union of different object shapes, how does TypeScript know which one you're dealing with? You give each variant a shared property with a unique literal value — a "tag" that tells them apart. When you check result.type === 'success', TypeScript reads that label and knows the object must be Success, so it lets you access .data. In the else branch, it knows it's Error, so .message is available.

String Literal Unions vs Enums

String literal unions and enums solve the same problem — restricting a value to specific options. Unions are preferred in most TypeScript code.

// Union — just a type, erased at compile time (preferred)
type PaymentMethod = 'credit_card' | 'paypal' | 'crypto';

// Enum — generates real JavaScript (less common)
enum PaymentMethod {
  CreditCard = 'credit_card',
  PayPal = 'paypal',
  Crypto = 'crypto'
}
Union Enum
Runtime existence ❌ erased ✅ real JS object
Bundle size None Adds code
Loop over all values ❌ can't Object.values(PaymentMethod)
Rename a value Manual find/replace Change in one place
Autocomplete
// Union — you use the raw string
let method: PaymentMethod = 'credit_card';

// Enum — you reference the enum name
let method = PaymentMethod.CreditCard;

Use unions by default. Use enums when you need to iterate over all values (e.g., rendering a dropdown) or want centralized renaming.

type PaymentMethod = 'credit_card' | 'paypal' | 'crypto';

function processPayment(method: PaymentMethod, amount: number) {
  // TypeScript ensures only valid payment methods are passed
}

processPayment('credit_card', 50);  // ✅
processPayment('cash', 50);         // ❌ error

Interface vs Type — When to Use Which

Both can define object shapes. The differences:

Interface Type
Object shapes
extends extends & (intersection)
Declaration merging ✅ Re-declare to extend
Unions (A | B)
Primitives / tuples
Function signatures Verbose Clean

Rule of thumb:

  • Use interface for object shapes and data models (most common)
  • Use type for unions, function types, and anything that isn't a plain object shape
  • Either works for objects — pick one and be consistent

Key Takeaways

  • Interfaces define object shapes — compile-time only, no JavaScript output
  • ? = optional property, readonly = can't be changed
  • Interfaces can have method signatures but never implementations
  • Types are more flexible — unions, intersections, function types, primitives
  • Discriminated unions use a shared type property for type narrowing
  • interface extends vs type & — both combine types, different syntax
  • Declaration merging is unique to interfaces (useful for extending third-party types)
  • Default to interface for objects, type for everything else