Skip to content

Interfaces & Types#

Interfaces and type aliases are two ways to define custom types in TypeScript. Understanding when to use each is crucial for writing clean, maintainable code.

Interfaces#

Basic Interface#

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

const user: User = {
  id: 1,
  name: "Alice",
  email: "alice@example.com"
};

Optional Properties#

interface Product {
  id: number;
  name: string;
  description?: string; // Optional
  price: number;
  discount?: number;    // Optional
}

const product1: Product = {
  id: 1,
  name: "Laptop",
  price: 999.99
  // description and discount are optional
};

const product2: Product = {
  id: 2,
  name: "Mouse",
  description: "Wireless gaming mouse",
  price: 49.99,
  discount: 10
};

Readonly Properties#

interface Config {
  readonly apiUrl: string;
  readonly apiKey: string;
  timeout: number;
}

const config: Config = {
  apiUrl: "https://api.example.com",
  apiKey: "secret-key-123",
  timeout: 5000
};

config.timeout = 10000;              // ✅ OK
// config.apiUrl = "https://new.com"; // ❌ Error: Cannot assign to 'apiUrl' because it is a read-only property

Index Signatures#

// String index signature
interface StringMap {
  [key: string]: string;
}

const errors: StringMap = {
  email: "Invalid email",
  password: "Password too short",
  username: "Username taken"
};

// Number index signature
interface NumberArray {
  [index: number]: number;
}

const fibonacci: NumberArray = [1, 1, 2, 3, 5, 8, 13];

// Combined with known properties
interface Dictionary {
  [key: string]: any;
  count: number;      // Must be compatible with index signature
  toString(): string; // Methods are OK
}

Function Types in Interfaces#

interface MathOperation {
  (x: number, y: number): number;
}

const add: MathOperation = (x, y) => x + y;
const multiply: MathOperation = (x, y) => x * y;

// Interface with properties and methods
interface Calculator {
  brand: string;
  model: string;
  calculate(operation: string, x: number, y: number): number;
}

const calculator: Calculator = {
  brand: "Casio",
  model: "FX-991",
  calculate(operation, x, y) {
    switch (operation) {
      case "add": return x + y;
      case "subtract": return x - y;
      default: return 0;
    }
  }
};

Extending Interfaces#

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

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

const employee: Employee = {
  name: "Bob",
  age: 30,
  employeeId: 12345,
  department: "Engineering"
};

// Multiple inheritance
interface Timestamped {
  createdAt: Date;
  updatedAt: Date;
}

interface Article extends Person, Timestamped {
  title: string;
  content: string;
}

Type Aliases#

Basic Type Alias#

type ID = string | number;
type UserID = string;
type Email = string;

let userId: UserID = "user_123";
let email: Email = "user@example.com";

Object Type Aliases#

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

type User = {
  id: number;
  name: string;
  email: string;
  age?: number;
};

const point: Point = { x: 10, y: 20 };
const user: User = { id: 1, name: "Alice", email: "alice@example.com" };

Union Types#

type Status = "pending" | "approved" | "rejected";
type Result = Success | Error;

type Success = {
  type: "success";
  data: any;
};

type Error = {
  type: "error";
  message: string;
};

function handleResult(result: Result) {
  if (result.type === "success") {
    console.log(result.data);
  } else {
    console.error(result.message);
  }
}

Intersection Types#

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

type Employee = {
  employeeId: number;
  department: string;
};

type Staff = Person & Employee;

const staff: Staff = {
  name: "Charlie",
  age: 28,
  employeeId: 456,
  department: "Marketing"
};

// Combining multiple types
type Timestamped = {
  createdAt: Date;
  updatedAt: Date;
};

type Article = {
  title: string;
  content: string;
} & Timestamped;

Function Type Aliases#

type GreetFunction = (name: string) => string;
type MathOperation = (x: number, y: number) => number;
type Callback<T> = (error: Error | null, data: T | null) => void;

const greet: GreetFunction = (name) => `Hello, ${name}!`;

const add: MathOperation = (x, y) => x + y;

const handleResponse: Callback<User> = (error, data) => {
  if (error) {
    console.error(error);
  } else {
    console.log(data);
  }
};

Interface vs Type: When to Use Which?#

Use Interface When:#

  1. Defining object shapes

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

  2. You need declaration merging

    interface Window {
      myCustomProperty: string;
    }
    
    interface Window {
      anotherProperty: number;
    }
    
    // Both declarations merge into one
    

  3. Extending or implementing

    interface Animal {
      name: string;
    }
    
    interface Dog extends Animal {
      breed: string;
    }
    
    class Labrador implements Dog {
      name = "Buddy";
      breed = "Labrador";
    }
    

Use Type When:#

  1. Creating union types

    type Status = "pending" | "approved" | "rejected";
    type ID = string | number;
    

  2. Creating intersection types

    type Admin = User & { adminLevel: number };
    

  3. Defining primitive aliases

    type Email = string;
    type Age = number;
    

  4. Using mapped or conditional types

    type Readonly<T> = { readonly [P in keyof T]: T[P] };
    type NonNullable<T> = T extends null | undefined ? never : T;
    

Advanced Patterns#

Discriminated Unions#

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

function getArea(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.size ** 2;
    case "rectangle":
      return shape.width * shape.height;
  }
}

const circle: Shape = { kind: "circle", radius: 10 };
const square: Shape = { kind: "square", size: 20 };

Generic Interfaces#

interface Container<T> {
  value: T;
  getValue(): T;
  setValue(value: T): void;
}

class NumberContainer implements Container<number> {
  constructor(public value: number) {}

  getValue(): number {
    return this.value;
  }

  setValue(value: number): void {
    this.value = value;
  }
}

// Generic type with constraints
interface Response<T extends { id: number }> {
  data: T;
  status: number;
  error: string | null;
}

Mapped Types#

type Readonly<T> = {
  readonly [P in keyof T]: T[P];
};

type Partial<T> = {
  [P in keyof T]?: T[P];
};

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

type ReadonlyUser = Readonly<User>;
// { readonly id: number; readonly name: string; readonly email: string }

type PartialUser = Partial<User>;
// { id?: number; name?: string; email?: string }

Conditional Types#

type IsString<T> = T extends string ? true : false;

type A = IsString<string>;  // true
type B = IsString<number>;  // false

// Practical example
type NonNullable<T> = T extends null | undefined ? never : T;

type C = NonNullable<string | null>;  // string
type D = NonNullable<number | undefined>;  // number

Practical Examples#

API Response Types#

interface ApiResponse<T> {
  data: T;
  error: string | null;
  status: number;
  timestamp: number;
}

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

interface PaginatedResponse<T> {
  items: T[];
  page: number;
  pageSize: number;
  total: number;
  hasMore: boolean;
}

type UserListResponse = ApiResponse<PaginatedResponse<User>>;

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

Form State Management#

interface FormField<T = string> {
  value: T;
  error: string | null;
  touched: boolean;
  dirty: boolean;
}

interface LoginForm {
  email: FormField<string>;
  password: FormField<string>;
  rememberMe: FormField<boolean>;
}

type FormState = {
  [K in keyof LoginForm]: LoginForm[K];
};

const initialState: FormState = {
  email: { value: "", error: null, touched: false, dirty: false },
  password: { value: "", error: null, touched: false, dirty: false },
  rememberMe: { value: false, error: null, touched: false, dirty: false }
};

Event System#

interface Event<T = any> {
  type: string;
  payload: T;
  timestamp: number;
}

interface UserLoginEvent extends Event<{ userId: string; ip: string }> {
  type: "USER_LOGIN";
}

interface UserLogoutEvent extends Event<{ userId: string }> {
  type: "USER_LOGOUT";
}

interface ErrorEvent extends Event<{ message: string; code: number }> {
  type: "ERROR";
}

type AppEvent = UserLoginEvent | UserLogoutEvent | ErrorEvent;

function handleEvent(event: AppEvent): void {
  switch (event.type) {
    case "USER_LOGIN":
      console.log(`User ${event.payload.userId} logged in from ${event.payload.ip}`);
      break;
    case "USER_LOGOUT":
      console.log(`User ${event.payload.userId} logged out`);
      break;
    case "ERROR":
      console.error(`Error ${event.payload.code}: ${event.payload.message}`);
      break;
  }
}

Builder Pattern#

interface QueryBuilder {
  select(...fields: string[]): QueryBuilder;
  from(table: string): QueryBuilder;
  where(condition: string): QueryBuilder;
  orderBy(field: string, direction: "ASC" | "DESC"): QueryBuilder;
  limit(count: number): QueryBuilder;
  build(): string;
}

class SqlQueryBuilder implements QueryBuilder {
  private query: {
    select: string[];
    from: string;
    where: string[];
    orderBy: string | null;
    limit: number | null;
  } = {
    select: [],
    from: "",
    where: [],
    orderBy: null,
    limit: null
  };

  select(...fields: string[]): QueryBuilder {
    this.query.select = fields;
    return this;
  }

  from(table: string): QueryBuilder {
    this.query.from = table;
    return this;
  }

  where(condition: string): QueryBuilder {
    this.query.where.push(condition);
    return this;
  }

  orderBy(field: string, direction: "ASC" | "DESC"): QueryBuilder {
    this.query.orderBy = `${field} ${direction}`;
    return this;
  }

  limit(count: number): QueryBuilder {
    this.query.limit = count;
    return this;
  }

  build(): string {
    const parts: string[] = [];

    parts.push(`SELECT ${this.query.select.join(", ")}`);
    parts.push(`FROM ${this.query.from}`);

    if (this.query.where.length > 0) {
      parts.push(`WHERE ${this.query.where.join(" AND ")}`);
    }

    if (this.query.orderBy) {
      parts.push(`ORDER BY ${this.query.orderBy}`);
    }

    if (this.query.limit !== null) {
      parts.push(`LIMIT ${this.query.limit}`);
    }

    return parts.join(" ");
  }
}

// Usage
const query = new SqlQueryBuilder()
  .select("id", "name", "email")
  .from("users")
  .where("age > 18")
  .where("status = 'active'")
  .orderBy("name", "ASC")
  .limit(10)
  .build();

console.log(query);
// SELECT id, name, email FROM users WHERE age > 18 AND status = 'active' ORDER BY name ASC LIMIT 10

Best Practices#

  1. Prefer interfaces for object types in public APIs

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

  2. Use types for unions, intersections, and utility types

    type Status = "active" | "inactive";
    type Admin = User & { role: "admin" };
    

  3. Use consistent naming conventions

    interface UserProps { /* ... */ }  // PascalCase for interfaces
    type UserId = string;               // PascalCase for types
    

  4. Keep interfaces focused and single-purpose

    interface User { /* user data */ }
    interface Timestamped { createdAt: Date; updatedAt: Date; }
    

  5. Use readonly for immutable properties

    interface Config { readonly apiKey: string; }
    

  6. Avoid mixing interface and type for same purpose

    // ❌ Don't do this
    interface User { id: number; }
    type Admin = User & { role: string };
    
    // ✅ Be consistent
    interface User { id: number; }
    interface Admin extends User { role: string; }
    

Next Steps#

  • Learn about Classes and how to implement interfaces
  • Explore Generics for reusable type-safe code
  • Master Utility Types built into TypeScript