← Essays
§ Essay

interface vs type: when each one earns it

TypeScript gives you two ways to declare an object shape, and most projects pick one by accident. interface User { name: string } and type User = { name: string } look like the same statement spelled twice. In nine out of ten places in your code they are. The tenth is where the decision starts to compound, and the codebase that mixed them at random pays interest forever.

Start with what they share. For a plain object shape, the two forms produce identical types from the type checker’s point of view:

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

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

Same checks pass, same errors fire, same auto-completion in the editor. If your codebase only ever describes object shapes, the answer is “pick one and stop thinking about it.” The reason this essay exists is that real codebases do not stop at object shapes.

The boring answer is the one that scales: default to interface, switch to type when the language forces your hand, and never mix the two by accident.

— the house style

What only type can do

A type alias names any type expression. An interface only names an object type. The asymmetry matters as soon as you reach for a feature that is not an object.

// Unions — required for tagged variants and nullable shapes
type ID = string | number;
type Maybe<T> = T | null;

// Tuples — fixed-shape arrays interfaces cannot describe
type Pair<T> = [T, T];

// Primitive aliases — the type checker treats them as distinct names
type Email = string;
type UserId = string & { __brand: 'UserId' };

// Mapped types — transform every key of another type
type Optional<T> = { [K in keyof T]?: T[K] };

// Conditional types — branch on whether one type extends another
type Unwrap<T> = T extends Promise<infer U> ? U : T;

// Template literal types — string types built from other strings
type EventName = `on${Capitalize<string>`;

None of those have an interface equivalent. They are not interface limitations — they are language features that only type exposes. If you need any of them, the choice is made for you.

The literal-type narrowing patterns from when string is a lie are a good example: every discriminated union you write is a type, not an interface, because the union itself cannot be an interface.

What only interface can do

The other direction is shorter but more important than it looks. Interfaces support declaration merging: two interface declarations with the same name in the same scope combine their members into one type. Type aliases do not. The second declaration is an error.

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

// User is now { id: string; name: string }

type Admin = { id: string };
type Admin = { name: string };  // Error: duplicate identifier

On its own this looks like a footgun — quietly merging types is rarely what you want inside one file. The reason it exists is module augmentation. When you need to extend a type from a third-party library — add a property to Window, register a custom theme on the Express Request, attach a field to process.env — the only mechanism is to re-open that library’s interface from inside your code.

// global.d.ts
declare global {
  interface Window {
    analytics?: { track: (e: string) => void };
  }
}

// somewhere in app code
window.analytics?.track('page_view');  // typed, no any

If Window were a type alias upstream, you could not augment it. You would write a wrapper, or fall back to any. This is the single biggest reason the TypeScript handbook recommends interfaces for public types your library exports — the consumers might need to extend them.

The performance footnote

The TypeScript team itself recommends preferring interfaces for object shapes, citing compiler performance. The mechanism: when you write type Big = A & B & C, the compiler computes the intersection on demand at every reference site. When you write interface Big extends A, B, C {}, the resolved member list is cached on the interface itself.

For a typical application this is invisible. The place it shows up is in projects that consume large generated type packages — something like @types/stripe, an OpenAPI-generated client, or a Prisma schema with hundreds of models. Compile times that grew from 3 seconds to 30 are usually a story about intersections inside intersections, not about lines of code.

The rule of thumb: if the type names a thing your code talks about, use interface. The cache wins are real and the cost is zero.

How errors read

A small thing that compounds. When a type-check fails on an interface, the error message tends to keep the interface name. When it fails on an intersection, the message can expand the intersection inline:

// interface — readable
// "Property 'email' is missing in type 'Partial<User>' but required in type 'User'"

// intersection — sometimes legible, sometimes a wall of types
// "Type ... is not assignable to type '{ id: string } & { name: string } & { email: string }'"

Modern TypeScript versions are much better at preserving names in intersection errors than they were three years ago, so this is a minor tiebreaker now rather than a deciding factor. But it is still a tiebreaker in the same direction.

The rule that scales

  1. Default to interface when the type is a named object shape.
  2. Use type when you need a union, tuple, mapped, conditional, or template literal type, or when you are aliasing a primitive with a brand.
  3. Use type for shapes that compose with & from other types — the alias gives you a name to refer to.
  4. Pick once and lint it. @typescript-eslint/consistent-type-definitions can enforce the rule. The cost of revisiting the question every time you write a type is more than the cost of any rule.

The short version: both work. The codebases that picked the boring rule and stuck with it are the ones whose types still compile in under five seconds five years in. That is the answer that earns its keep.