TypeScript: Type Aliases vs Interfaces

Introduction

This is a question I’ve been asked on almost every team I’ve worked on. Junior developers ask it when they first encounter TypeScript. Mid-level developers ask it again once they’ve been burned by a subtle difference they didn’t know about. Senior developers still debate it in code reviews.

After a decade of writing TypeScript across enterprise Angular applications for companies like FedEx, NCR Voyix, and Orpak — and React applications for Kent CamTech and Payback — I’ve landed on a set of practical rules that actually hold up in production codebases. Not theoretical ones. Real ones.

Let me walk you through what I know.

The Short Answer (That Nobody Reads)

If you want the TL;DR before the nuance:

  • Use interface for objects that describe the shape of something — component props, API response models, service contracts, class implementations.
  • Use type for everything else — unions, intersections, primitives, computed types, utility types, conditional types.

If you only remember one rule, that’s the one. Now let me explain why, with examples from actual project work.

What They Have in Common

Before the differences, let’s be clear about what they share. Both do the same job for basic object shapes:

// These are functionally identical for simple object shapes
interface AlertEvent {
  id: string;
  severity: 'critical' | 'warning' | 'info';
  timestamp: Date;
  vehicleId: string;
}

type AlertEvent = {
  id: string;
  severity: 'critical' | 'warning' | 'info';
  timestamp: Date;
  vehicleId: string;
};

Both work for function signatures:

interface FormatCurrency {
  (amount: number, locale: string): string;
}

type FormatCurrency = (amount: number, locale: string) => string;

For basic object and function shapes, they’re interchangeable. The differences emerge when you need more.

Where They Diverge: Declaration Merging

This is the one that catches people off guard. Interfaces support declaration merging — you can define the same interface in multiple places and TypeScript will merge them into one.

interface ThemeTokens {
  colorPrimary: string;
  colorSurface: string;
}

// Later in another file, or even another library
interface ThemeTokens {
  colorTextPrimary: string;
  borderRadius: string;
}

// TypeScript sees this as one merged interface:
// { colorPrimary, colorSurface, colorTextPrimary, borderRadius }

On the Orpak project, this was actually useful. The base CSS style guide defined a core ThemeTokens interface, and each client-specific theme file extended it by merging additional token definitions without touching the base. Clean, no inheritance chain needed.

Type aliases cannot do this. If you declare the same type name twice, TypeScript throws an error immediately.

type ThemeTokens = {
  colorPrimary: string;
};

// ❌ Error: Duplicate identifier 'ThemeTokens'
type ThemeTokens = {
  colorTextPrimary: string;
};

When does declaration merging matter? When you’re writing a library, extending third-party types, or designing a plugin architecture. For most application code, it’s irrelevant — which is fine. Know it exists, reach for it when you need it.

Where Types Win: Unions and Intersections

This is where type has a clear advantage — and where I use it almost exclusively in real projects.

Union types

On the CarCam fleet platform, a WebSocket event could arrive in several shapes depending on what happened on the road:

type GPSUpdate = {
  type: 'gps';
  vehicleId: string;
  lat: number;
  lng: number;
  speed: number;
  timestamp: Date;
};

type AlertFired = {
  type: 'alert';
  vehicleId: string;
  alertType: 'harsh_brake' | 'speeding' | 'geofence_exit';
  severity: 'critical' | 'warning';
  timestamp: Date;
};

type VideoFrame = {
  type: 'video_frame';
  vehicleId: string;
  cameraId: string;
  frameData: string;
  timestamp: Date;
};

// This is the actual WebSocket message type — a union
type WebSocketMessage = GPSUpdate | AlertFired | VideoFrame;

You cannot express a union with an interface. Period.

// ❌ This is not valid TypeScript
interface WebSocketMessage = GPSUpdate | AlertFired | VideoFrame;

When your application processes these events, TypeScript narrows the type automatically based on the discriminant field:

function handleMessage(message: WebSocketMessage) {
  switch (message.type) {
    case 'gps':
      // TypeScript knows this is GPSUpdate here
      updateVehiclePosition(message.vehicleId, message.lat, message.lng);
      break;
    case 'alert':
      // TypeScript knows this is AlertFired here
      triggerAlertNotification(message.alertType, message.severity);
      break;
    case 'video_frame':
      // TypeScript knows this is VideoFrame here
      renderVideoFrame(message.cameraId, message.frameData);
      break;
  }
}

This pattern — discriminated unions — was central to how we handled WebSocket events on CarCam. TypeScript made the entire message routing type-safe with zero runtime type checks needed.

Intersection types

On the FedEx ECAM project, we had a common pattern: a base entity with audit fields added by the backend. Intersection types modelled this cleanly:

type Auditable = {
  createdAt: Date;
  updatedAt: Date;
  createdBy: string;
  updatedBy: string;
};

type Ticket = {
  id: string;
  accountId: string;
  subject: string;
  status: 'open' | 'in_progress' | 'resolved' | 'closed';
  priority: 'low' | 'medium' | 'high' | 'critical';
};

// A ticket as it comes from the API — has both
type AuditableTicket = Ticket & Auditable;

You can approximate this with interface extends, but intersections are more composable and don’t require an explicit naming ceremony for every combination.

Practical Patterns from Real Projects

API response models — I use interface

API responses describe the shape of an object. They’re natural interface territory, and declaration merging occasionally comes in handy when you need to extend a third-party type definition.

// From the NCR Vision project — terminal health data
interface Terminal {
  id: string;
  serialNumber: string;
  type: 'ATM' | 'POS';
  location: {
    country: string;
    city: string;
    storeId: string;
  };
  status: TerminalStatus;
  lastHeartbeat: Date;
}

interface TerminalStatus {
  online: boolean;
  faultCode: string | null;
  softwareVersion: string;
  lastReboot: Date;
}

Component state — I use type

Component state often involves unions, optional states, and computed shapes. type fits naturally here.

// From the ECAM Angular migration
type LoadingState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: string };

// Usage in a component
type TicketListState = LoadingState<AuditableTicket[]>;

This pattern replaced a mess of separate isLoading, error, data boolean flags that were getting out of sync. When the state is a union, it’s impossible for status: 'success' and data: undefined to coexist — the types prevent it.

Function parameters and return types — either works, I prefer type

// Service contract on the Payback CMS
type FetchCampaignsOptions = {
  partnerId: string;
  status?: 'active' | 'paused' | 'ended';
  page?: number;
  pageSize?: number;
};

type PaginatedResult<T> = {
  items: T[];
  total: number;
  page: number;
  pageSize: number;
  hasNextPage: boolean;
};

// Clean, readable, no interface boilerplate
async function fetchCampaigns(
  options: FetchCampaignsOptions
): Promise<PaginatedResult<Campaign>> {
  // ...
}

Class implementations — use interface

When a class implements a contract, interface is the right tool. It reads as a contract, and TypeScript’s implements keyword works with it naturally.

// From the NCR Vision NUI library
interface ComponentTheme {
  applyTheme(tokens: ThemeTokens): void;
  resetTheme(): void;
  getCurrentTheme(): ThemeTokens;
}

class DashboardWidget implements ComponentTheme {
  applyTheme(tokens: ThemeTokens) {
    /* ... */
  }
  resetTheme() {
    /* ... */
  }
  getCurrentTheme(): ThemeTokens {
    /* ... */
  }
}

You can implement a type too, but it reads strangely. interface signals “this is a contract” in a way that type doesn’t.

The Utility Types Argument

One place type wins definitively: utility types. TypeScript’s built-in utility types return types, not interfaces, and combining them works cleanly with type aliases.

interface UserProfile {
  id: string;
  name: string;
  email: string;
  role: 'admin' | 'operator' | 'viewer';
  lastLogin: Date;
  preferences: UserPreferences;
}

// Creating a form model — only the editable fields, all optional
type UpdateProfileForm = Partial<
  Pick<UserProfile, 'name' | 'email' | 'preferences'>
>;

// Creating a safe public version — no sensitive fields
type PublicProfile = Omit<UserProfile, 'lastLogin' | 'preferences'>;

// A readonly version for display components
type ProfileDisplay = Readonly<PublicProfile>;

This was a pattern I used extensively on the Orpak and ECAM projects — deriving types from a single source of truth rather than maintaining parallel type definitions that drift apart.

Performance and Error Messages

One practical reason to prefer interface for large object shapes: TypeScript caches interface types by name, so they’re faster to check in large projects. With complex type aliases, TypeScript sometimes has to re-evaluate the full type expression wherever it’s used.

You probably won’t notice this on a small codebase, but on a project like ECAM — 3+ years of accumulated TypeScript with dozens of service types — it starts to matter in build times.

The other thing: TypeScript error messages for interfaces tend to be more readable. With a type alias that’s been through several intersections and utility transforms, the error message can print the entire expanded type, which in large models is almost unreadable. A named interface just shows the interface name.

// With interface — clean error message
// Argument of type 'Ticket' is not assignable to parameter of type 'AuditableTicket'.
//   Property 'createdAt' is missing in type 'Ticket'.

// With a complex intersected type alias — sometimes shows the whole expanded shape
// Argument of type '{ id: string; accountId: string; ... }' is not assignable to
// parameter of type '{ id: string; ... } & { createdAt: Date; updatedAt: Date; ... }'

My Personal Rules

After all of this, here’s what I actually apply consistently:

SituationWhat I use
Object shape from an APIinterface
Class contract / implementsinterface
Union of multiple typestype
Intersection of multiple typestype
Component state with multiple statestype (discriminated union)
Utility type combinationstype
Function parametersEither — I use type for consistency
Extending third-party typesinterface (declaration merging)
Primitive aliases (type ID = string)type

The Rule I Ignore

A lot of people say “be consistent — pick one and stick to it everywhere.” I understand the appeal of that rule, and it’s fine for small teams or new codebases. But on large enterprise projects, pretending that type and interface are fully interchangeable leads to friction in exactly the places where the distinction matters: union types, declaration merging, class contracts.

I’d rather have a team that understands why they’re choosing each one than a team following a blanket rule they can’t explain.

Conclusion

The type vs interface debate doesn’t need to be a religious war. They overlap significantly, but the differences are real and worth understanding. Once you know them, the right choice in each situation becomes obvious — not because of a rule someone told you, but because you understand what you’re modelling.

If you take one thing from this: reach for type when your shape involves unions, intersections, or utility type transforms. Reach for interface when you’re describing a standalone object contract — especially if a class will implement it or if you might need to extend it from outside your own codebase.

Everything else is just familiarity.

Resources