TypeScript Blitz - Day 2: Interfaces vs Types & Object Typing

Master TypeScript interfaces and types - learn when to use each, optional properties, readonly, index signatures, and build complex type-safe data structures

18 min read

Goal

After this lesson you’ll know when to use interface, when type, and how to model complex data structures like in real projects.


Theory Part (20 min)

1. Interface vs Type - Key Difference

// Interface - defines object shape
interface User {
  id: number;
  name: string;
  email: string;
}

// Type - alias for any type
type UserId = number;
type UserName = string;
type User2 = {
  id: number;
  name: string;
  email: string;
};

Looks similar? Yes! But there are differences:

Interface can:

// ✅ 1. Be extended (extends)
interface Animal {
  name: string;
}

interface Dog extends Animal {
  breed: string;
}

const myDog: Dog = {
  name: "Buddy",
  breed: "Labrador"
};

// ✅ 2. Be merged (declaration merging)
interface Window {
  title: string;
}

interface Window {
  size: number;
}

// TypeScript merges both - Window now has title and size!
const win: Window = {
  title: "Hello",
  size: 100
};

Type can:

// ✅ 1. Create union types
type Status = "success" | "error" | "loading";
type ID = string | number;

// ✅ 2. Create intersection types
type Timestamp = {
  createdAt: Date;
};

type Author = {
  author: string;
};

type Article = Timestamp & Author & {
  title: string;
  content: string;
};

// ✅ 3. Alias primitives and other types
type Age = number;
type Callback = (data: string) => void;

// ✅ 4. Use advanced features (mapped types, conditional types)
type Readonly<T> = {
  readonly [K in keyof T]: T[K];
};

Golden Rule: Use interface by default for objects. Use type when you must (unions, intersections, advanced features).


2. Optional Properties - Fields That May Not Exist

interface User {
  id: number;
  name: string;
  email: string;
  phone?: string;        // 👈 optional - can be undefined
  age?: number;
  address?: {
    street: string;
    city: string;
  };
}

const user1: User = {
  id: 1,
  name: "John",
  email: "john@example.com"
  // phone, age, address - don't have to exist
};

const user2: User = {
  id: 2,
  name: "Anna",
  email: "anna@example.com",
  phone: "+1 123 456 789"  // can be present
};

// Using optional properties
function printPhone(user: User) {
  // ❌ console.log(user.phone.length); // Error! phone can be undefined
  
  // ✅ Check if exists
  if (user.phone) {
    console.log(user.phone.length); // OK
  }
  
  // ✅ Optional chaining
  console.log(user.phone?.length); // undefined if phone doesn't exist
  
  // ✅ Nullish coalescing
  console.log(user.phone ?? "No phone"); // default value
}

3. Readonly - Fields That Cannot Be Changed

interface Config {
  readonly apiUrl: string;      // 👈 cannot change after creation
  readonly timeout: number;
  retries: number;               // can be changed
}

const config: Config = {
  apiUrl: "https://api.example.com",
  timeout: 5000,
  retries: 3
};

// config.apiUrl = "https://other.com";  // ❌ Error! readonly
config.retries = 5;                      // ✅ OK

// Readonly for entire object
interface User {
  id: number;
  name: string;
}

const user: Readonly<User> = {
  id: 1,
  name: "John"
};

// user.name = "Anna"; // ❌ Error! everything readonly

When to use readonly:

  • Configurations
  • Props in React
  • Immutable data structures
  • When you want to prevent accidental mutations

4. Index Signatures - Dynamic Keys

// Object with any string keys → number values
interface StringNumberMap {
  [key: string]: number;
}

const scores: StringNumberMap = {
  math: 95,
  english: 87,
  science: 92,
  "physical education": 88  // any string as key
};

console.log(scores["math"]);        // 95
console.log(scores["history"]);     // undefined (but TypeScript thinks it's number)

// With specific fields + index signature
interface Dictionary {
  [key: string]: string | number;  // 👈 dynamic fields
  length: number;                   // 👈 specific field (must match index signature!)
}

const dict: Dictionary = {
  length: 3,
  word1: "hello",
  word2: "world",
  count: 42
};

// Record<K, V> - better way for simpler cases
type UserMap = Record<string, User>;  // same as { [key: string]: User }

const users: UserMap = {
  "user1": { id: 1, name: "John", email: "john@example.com" },
  "user2": { id: 2, name: "Anna", email: "anna@example.com" }
};

5. Extending Interfaces vs Intersection Types

// EXTENDING INTERFACES - inheritance
interface Animal {
  name: string;
  age: number;
}

interface Dog extends Animal {
  breed: string;
  bark(): void;
}

interface Cat extends Animal {
  color: string;
  meow(): void;
}

// Dog HAS all fields from Animal + its own
const dog: Dog = {
  name: "Buddy",
  age: 3,
  breed: "Labrador",
  bark() { console.log("Woof!"); }
};

// Can extend multiple interfaces
interface Pet {
  owner: string;
}

interface ServiceDog extends Dog, Pet {
  certificationId: string;
}

// INTERSECTION TYPES (&) - combining types
type Timestamp = {
  createdAt: Date;
  updatedAt: Date;
};

type Author = {
  authorId: number;
  authorName: string;
};

type Article = Timestamp & Author & {
  title: string;
  content: string;
};

// Article has ALL fields from Timestamp, Author and its own
const article: Article = {
  createdAt: new Date(),
  updatedAt: new Date(),
  authorId: 1,
  authorName: "John",
  title: "TypeScript Guide",
  content: "..."
};

// Difference extends vs &:
// extends - for interfaces, more "class-like" approach
// & - for types, more flexible, works with unions

6. Discriminated Unions in Practice

// Modeling different post types
interface TextPost {
  type: "text";           // 👈 discriminator
  content: string;
  wordCount: number;
}

interface ImagePost {
  type: "image";          // 👈 discriminator
  imageUrl: string;
  width: number;
  height: number;
}

interface VideoPost {
  type: "video";          // 👈 discriminator
  videoUrl: string;
  duration: number;
  thumbnail: string;
}

type Post = TextPost | ImagePost | VideoPost;

function renderPost(post: Post) {
  switch (post.type) {
    case "text":
      return `Text: ${post.content}`;           // post: TextPost
    case "image":
      return `Image: ${post.imageUrl}`;         // post: ImagePost
    case "video":
      return `Video: ${post.videoUrl}`;         // post: VideoPost
  }
}

Practice Part (35 min)

Exercise 1: Event System in E-commerce App (15 min)

Build a type-safe event system for an e-commerce application.

// TODO: Define discriminated union for different events

// 1. UserClickedEvent
//    - type: "user_clicked"
//    - elementId: string
//    - timestamp: number
//    - pageUrl: string

// 2. ProductViewedEvent
//    - type: "product_viewed"
//    - productId: number
//    - productName: string
//    - timestamp: number
//    - userId?: string (optional)

// 3. AddToCartEvent
//    - type: "add_to_cart"
//    - productId: number
//    - quantity: number
//    - timestamp: number
//    - userId: string

// 4. PurchaseEvent
//    - type: "purchase"
//    - orderId: string
//    - totalAmount: number
//    - timestamp: number
//    - userId: string
//    - products: Array<{ productId: number; quantity: number }>

// Create union type for all events
type AppEvent = /* TODO */;

// Function to log events
function logEvent(event: AppEvent): void {
  // TODO: Implement switch on event.type
  // Each case should log specific information for that event
}

// Function to filter events by userId (returns only those with userId)
function filterByUser(events: AppEvent[], userId: string): AppEvent[] {
  // TODO: Filter events that have userId === userId
  // Hint: use type guard "userId" in event
}

// Tests - uncomment after implementation
// const events: AppEvent[] = [
//   { type: "user_clicked", elementId: "btn-1", timestamp: Date.now(), pageUrl: "/home" },
//   { type: "product_viewed", productId: 123, productName: "Laptop", timestamp: Date.now(), userId: "user-1" },
//   { type: "add_to_cart", productId: 123, quantity: 1, timestamp: Date.now(), userId: "user-1" },
// ];

// events.forEach(logEvent);
// console.log(filterByUser(events, "user-1"));

Solution:

Click to see solution
interface UserClickedEvent {
  type: "user_clicked";
  elementId: string;
  timestamp: number;
  pageUrl: string;
}

interface ProductViewedEvent {
  type: "product_viewed";
  productId: number;
  productName: string;
  timestamp: number;
  userId?: string;
}

interface AddToCartEvent {
  type: "add_to_cart";
  productId: number;
  quantity: number;
  timestamp: number;
  userId: string;
}

interface PurchaseEvent {
  type: "purchase";
  orderId: string;
  totalAmount: number;
  timestamp: number;
  userId: string;
  products: Array<{ productId: number; quantity: number }>;
}

type AppEvent = UserClickedEvent | ProductViewedEvent | AddToCartEvent | PurchaseEvent;

function logEvent(event: AppEvent): void {
  console.log(`[${new Date(event.timestamp).toISOString()}]`);
  
  switch (event.type) {
    case "user_clicked":
      console.log(`User clicked ${event.elementId} on ${event.pageUrl}`);
      break;
    
    case "product_viewed":
      console.log(`Product viewed: ${event.productName} (ID: ${event.productId})`);
      if (event.userId) {
        console.log(`  by user: ${event.userId}`);
      }
      break;
    
    case "add_to_cart":
      console.log(`Added to cart: Product ${event.productId}, quantity: ${event.quantity}`);
      console.log(`  by user: ${event.userId}`);
      break;
    
    case "purchase":
      console.log(`Purchase completed: Order ${event.orderId}, total: $${event.totalAmount}`);
      console.log(`  by user: ${event.userId}`);
      console.log(`  products: ${event.products.length}`);
      break;
  }
}

function filterByUser(events: AppEvent[], userId: string): AppEvent[] {
  return events.filter(event => {
    // Type guard - check if event has userId field
    if ("userId" in event) {
      return event.userId === userId;
    }
    return false;
  });
}

Exercise 2: Configuration with Key Validation (10 min)

Create type-safe configuration for an application.

// TODO: Define types for configuration

// 1. DatabaseConfig
//    - host: string
//    - port: number
//    - username: string
//    - password: string
//    - database: string
//    - readonly ssl: boolean (readonly!)

// 2. CacheConfig
//    - host: string
//    - port: number
//    - ttl: number (time to live in seconds)

// 3. ApiConfig
//    - baseUrl: string
//    - readonly timeout: number (readonly!)
//    - retries: number
//    - apiKey: string

// Create type that enforces you must have all three configs
type AppConfig = {
  database: DatabaseConfig;
  cache: CacheConfig;
  api: ApiConfig;
};

// Function to validate configuration
function validateConfig(config: AppConfig): boolean {
  // TODO: Check if:
  // - database.port is between 1000 and 65535
  // - cache.ttl is greater than 0
  // - api.timeout is greater than 0
  // - api.baseUrl starts with "http://" or "https://"
  
  return true; // or false if validation fails
}

// Test
// const config: AppConfig = {
//   database: {
//     host: "localhost",
//     port: 5432,
//     username: "admin",
//     password: "secret",
//     database: "myapp",
//     ssl: true
//   },
//   cache: {
//     host: "localhost",
//     port: 6379,
//     ttl: 3600
//   },
//   api: {
//     baseUrl: "https://api.example.com",
//     timeout: 5000,
//     retries: 3,
//     apiKey: "abc123"
//   }
// };

// console.log(validateConfig(config));
// config.database.ssl = false; // ❌ Should be error - readonly!

Solution:

Click to see solution
interface DatabaseConfig {
  host: string;
  port: number;
  username: string;
  password: string;
  database: string;
  readonly ssl: boolean;
}

interface CacheConfig {
  host: string;
  port: number;
  ttl: number;
}

interface ApiConfig {
  baseUrl: string;
  readonly timeout: number;
  retries: number;
  apiKey: string;
}

type AppConfig = {
  database: DatabaseConfig;
  cache: CacheConfig;
  api: ApiConfig;
};

function validateConfig(config: AppConfig): boolean {
  // Validate database
  if (config.database.port < 1000 || config.database.port > 65535) {
    console.error("Database port must be between 1000 and 65535");
    return false;
  }
  
  // Validate cache
  if (config.cache.ttl <= 0) {
    console.error("Cache TTL must be greater than 0");
    return false;
  }
  
  // Validate API
  if (config.api.timeout <= 0) {
    console.error("API timeout must be greater than 0");
    return false;
  }
  
  if (!config.api.baseUrl.startsWith("http://") && 
      !config.api.baseUrl.startsWith("https://")) {
    console.error("API baseUrl must start with http:// or https://");
    return false;
  }
  
  return true;
}

Exercise 3: API Response with Extending Interfaces (10 min)

// TODO: Create type hierarchy for API responses

// BaseResponse - common fields for all responses
// - timestamp: Date
// - requestId: string

// PaginatedResponse extends BaseResponse
// - page: number
// - pageSize: number
// - totalPages: number
// - totalItems: number

// DataResponse<T> extends BaseResponse
// - data: T
// - status: "success"

// ErrorResponse extends BaseResponse
// - error: string
// - errorCode: number
// - status: "error"

// PaginatedDataResponse<T> - combines PaginatedResponse and DataResponse
// Hint: use intersection (&)

// Example usage:
interface User {
  id: number;
  name: string;
  email: string;
}

// type UsersResponse = PaginatedDataResponse<User[]>;

// const response: UsersResponse = {
//   timestamp: new Date(),
//   requestId: "req-123",
//   page: 1,
//   pageSize: 10,
//   totalPages: 5,
//   totalItems: 50,
//   data: [
//     { id: 1, name: "John", email: "john@example.com" }
//   ],
//   status: "success"
// };

Solution:

Click to see solution
interface BaseResponse {
  timestamp: Date;
  requestId: string;
}

interface PaginatedResponse extends BaseResponse {
  page: number;
  pageSize: number;
  totalPages: number;
  totalItems: number;
}

interface DataResponse<T> extends BaseResponse {
  data: T;
  status: "success";
}

interface ErrorResponse extends BaseResponse {
  error: string;
  errorCode: number;
  status: "error";
}

// Intersection - combines all fields
type PaginatedDataResponse<T> = PaginatedResponse & DataResponse<T>;

// Example usage
interface User {
  id: number;
  name: string;
  email: string;
}

type UsersResponse = PaginatedDataResponse<User[]>;

const response: UsersResponse = {
  timestamp: new Date(),
  requestId: "req-123",
  page: 1,
  pageSize: 10,
  totalPages: 5,
  totalItems: 50,
  data: [
    { id: 1, name: "John", email: "john@example.com" },
    { id: 2, name: "Anna", email: "anna@example.com" }
  ],
  status: "success"
};

Quick Review - Key Concepts

1. What’s the difference between Interface and Type?

Answer

Interface:

  • Primarily for defining object shapes
  • Can be extended via extends
  • Supports declaration merging (can add fields in different places)
  • More readable error messages

Type:

  • Can alias any type (primitives, unions, tuples, functions)
  • Supports union types (|) and intersection types (&)
  • Supports advanced features (mapped types, conditional types)
  • Doesn’t support declaration merging

When I use what:

  • Interface - for public API, object/class definitions, when I want extends
  • Type - for unions, intersections, tuple types, mapped types, type utilities

Example:

// Interface - objects
interface User {
  id: number;
  name: string;
}

// Type - unions, advanced
type Status = "idle" | "loading" | "success" | "error";
type Result<T> = { success: true; data: T } | { success: false; error: string };

In practice, they can often be used interchangeably for simple objects, but these are conventions that help code readability.

2. What is declaration merging and when is it useful?

Answer

Declaration merging is a TypeScript feature where multiple declarations of the same interface are automatically merged into one.

Example:

interface Window {
  title: string;
}

interface Window {
  size: number;
}

// TypeScript merges both declarations:
// interface Window {
//   title: string;
//   size: number;
// }

When useful:

  1. Extending global types (e.g., Window, global)
  2. Library augmentation - adding types to external libraries
  3. Plugin systems - different modules add their own fields

Real-world example:

// Extending Express Request
declare global {
  namespace Express {
    interface Request {
      user?: {
        id: string;
        email: string;
      };
    }
  }
}

// Now req.user is typed!

Type doesn’t support merging - you’ll get “Duplicate identifier” error.

3. When to use readonly and what’s the difference between readonly and const?

Answer

readonly - property cannot be changed after object initialization

const - variable cannot be reassigned

Example:

// const - can't assign new value to variable
const user = { name: "John" };
// user = { name: "Anna" }; // ❌ Error!
user.name = "Anna";         // ✅ OK - can mutate object

// readonly - can't change property
interface User {
  readonly id: number;
  name: string;
}

const user2: User = { id: 1, name: "John" };
// user2.id = 2;    // ❌ Error! readonly
user2.name = "Anna"; // ✅ OK

When I use readonly:

  • Identifiers (id, uuid) - shouldn’t change
  • Configuration objects - immutable after creation
  • React props - props shouldn’t be mutated
  • Timestamps - createdAt shouldn’t change

Readonly utility:

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

const user: Readonly<User> = { id: 1, name: "John" };
// user.id = 2;   // ❌ Error!
// user.name = "Anna"; // ❌ Error!

Important: readonly is a compile-time check - at runtime JavaScript normally allows mutations!

4. What is an index signature and when to use it?

Answer

Index signature allows defining a type for dynamic object keys.

Syntax:

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

const scores: StringMap = {
  math: 95,
  english: 87,
  anyOtherKey: 92  // any string as key
};

When I use it:

  1. Dictionaries/Maps - when keys are dynamic
  2. Configuration objects - flexible config
  3. API responses - when structure is not known upfront
  4. Cache/Storage - key-value pairs

With specific fields:

interface Config {
  [key: string]: string | number;  // dynamic fields
  version: number;                  // specific field (must be string | number!)
}

Better alternatives in simple cases:

// Record<K, V> - more readable
type UserCache = Record<string, User>;

// Map - runtime structure
const cache = new Map<string, User>();

Warning: Index signature makes type too permissive - obj.nonexistent returns the type, not undefined! Sometimes better to use Record or Map.

5. Extends vs Intersection (&) - when to use which?

Answer

Extends (interface):

interface Animal {
  name: string;
}

interface Dog extends Animal {
  breed: string;
}
// Dog has: name, breed

Intersection (&) (type):

type Animal = {
  name: string;
};

type Dog = Animal & {
  breed: string;
};
// Dog has: name, breed

Differences:

  1. Syntax:

    • extends - only for interfaces
    • & - for types, more flexible
  2. Error messages:

    • extends - more readable errors
    • & - sometimes cryptic errors
  3. Capabilities:

    • extends - inheritance, override methods
    • & - can combine unions, more flexibility

When I use what:

extends:

// Class/object hierarchy
interface Shape {
  color: string;
}

interface Circle extends Shape {
  radius: number;
}

& (intersection):

// Combining different concerns
type Timestamp = { createdAt: Date };
type Author = { author: string };
type Article = Timestamp & Author & { title: string };

// Mixins
type Flyable = { fly(): void };
type Swimmable = { swim(): void };
type Duck = Flyable & Swimmable & { quack(): void };

In practice: I use extends for clear hierarchies (OOP style), & for composition (functional style).


Checklist - What You Should Know After Day 2

  • Explain the difference Interface vs Type and when to use which
  • Use optional properties (?)
  • Apply readonly for immutability
  • Define index signatures for dynamic keys
  • Extend interfaces (extends)
  • Combine types via intersection (&)
  • Create discriminated unions with interfaces
  • Model complex data structures (API responses, configs, events)

Quick Reference Card

// Interface vs Type
interface User { id: number }      // Objects, extends, merging
type Status = "ok" | "error";      // Unions, intersections, advanced

// Optional & Readonly
interface Config {
  readonly url: string;  // Cannot change
  timeout?: number;      // May not exist
}

// Index signature
interface Dict {
  [key: string]: number;  // Dynamic keys
}

// Extends
interface Dog extends Animal {
  breed: string;
}

// Intersection
type Article = Timestamp & Author & { title: string };

// Discriminated Union
type Response = 
  | { status: "success"; data: T }
  | { status: "error"; error: string };

Tomorrow: Functions & Advanced Types - type functions with overloads

Back to blog