TypeScript – Interfaces vs Type Aliases

July 25, 20268 min readUpdated 9/5/2026

These two do almost the same job, which is exactly why the question keeps being asked. Most of the advice you will find is someone's preference dressed up as a rule. There are three real differences, and once you know them the choice mostly makes itself.

They start out identical

interface ProductSize {
  id: UUID;
  size: SizeName;
  price: number;
}

type ProductSizeAlias = {
  id: UUID;
  size: SizeName;
  price: number;
};

Those are the same type. Both can be implemented by a class, extended, used as a parameter type, made optional or readonly, and given an index signature. Neither is faster at runtime, because neither exists at runtime.

So the differences are at the edges. There are three.

Difference 1: type can name anything

An interface describes an object shape. A type gives a name to any type at all — and that is the difference you will hit first.

export type UUID = string;
export type ProductType = 'PIZZA' | 'DRINK';
export type SizeName = 'SMALL' | 'MEDIUM' | 'LARGE';
export type ToppingCategory = 'MEAT' | 'VEGGIE' | 'CHEESE';
export type OrderType = 'DELIVERY' | 'CARRYOUT';

None of those can be an interface. A union is not an object shape, and neither is a primitive alias, a tuple, or anything built with a mapped or conditional type.

The same applies to a union of object shapes, which the Angular admin store uses to say "either it worked, or here is why it did not":

export type Outcome = { ok: true } | { ok: false; failure: ApiFailure };

That is a discriminated union — lesson 10 — and it has to be a type.

Difference 2: interfaces merge, aliases do not

Declare an interface twice and TypeScript combines the declarations:

interface Product { id: UUID }
interface Product { name: string }

// Product now has both id and name.

Do the same with a type and it is an error: Duplicate identifier.

Inside your own codebase, merging is mostly a hazard — two declarations of the same name in different files, silently combining, is rarely what anyone intended. It exists for declaration merging across module boundaries, which is how you extend a type you do not own.

The canonical example, and one you will meet in any Vite project, is adding your own environment variables:

// src/vite-env.d.ts
interface ImportMetaEnv {
  readonly VITE_API_BASE_URL: string;
}

Vite declares ImportMetaEnv; your declaration merges into it; and import.meta.env.VITE_API_BASE_URL is now typed. There is no way to do that with a type, because you cannot reopen an alias.

The same trick is how you add a property to Express's Request, or a method to a library's options object. If you never need it — and most application code never does — this difference costs you nothing either way.

Difference 3: extends and & are not quite the same

Both can combine types. Interfaces use extends:

interface Timestamped {
  createdAt: string;
  updatedAt: string;
}

interface Product extends Timestamped {
  id: UUID;
  name: string;
}

Aliases use an intersection:

type Product = Timestamped & {
  id: UUID;
  name: string;
};

Same result here. They differ on conflict. extends checks that the child is compatible with the parent and errors if not:

// This does not compile.
interface Base { price: number }
interface Sale extends Base { price: string }
//        ~~~~
// Interface 'Sale' incorrectly extends interface 'Base'.
// Types of property 'price' are incompatible.

The intersection version reports nothing at the declaration. Instead price silently becomes number & string, which is never — and you find out much later, at the point where you try to assign one:

type Sale = Base & { price: string };   // no error here

const s: Sale = { price: 9.99 };        // error, a long way from the cause

Failing at the declaration is better than failing at every use site, which is the strongest argument for interface in the one case where they genuinely differ.

There is a secondary, practical difference: interfaces get a name in error messages and tsc caches them, while a complex intersection is expanded and printed in full. On a big project this shows up both as slower checks and as error messages the width of your screen.

Both take type parameters

Neither is limited to a fixed shape. Both can be generic, with the same syntax:

/** Spring's paginated envelope, trimmed to what the UI uses. */
export interface Page<T> {
  content: T[];
  totalElements: number;
  totalPages: number;
  number: number;
  size: number;
}

Page<Order> and Page<AdminUser> then describe the two paginated endpoints the admin area calls, with one declaration. Written as an alias it would be type Page<T> = { … } and behave identically — lesson 13 covers the mechanics.

Where the alias pulls ahead is when the type parameter is used for something other than a property type — a conditional, a mapped type, a union built from T. Interfaces cannot express any of those, so anything derived ends up as a type whatever your house style says.

Both handle recursion

Self-referencing types work either way, which matters more than it sounds because most real domain models are recursive somewhere. The app's is, two levels down:

export interface OrderItem {
  id: UUID;
  productId: UUID | null;
  productName: string;
// ...
  toppings: OrderItemTopping[];
}

A directly recursive alias is fine too, as long as the recursion goes through an object or array rather than being a bare self-reference:

type MenuNode = {
  name: string;
  children: MenuNode[];   // fine
};

type Bad = Bad | string;  // Type alias 'Bad' circularly references itself

The rule is that the compiler needs something concrete to stop at. A property, an array element or a function parameter all qualify; a union of yourself does not.

What the app does

The convention in the pizza codebase is easy to state, and it is the one most teams land on.

Interface for a named object shape — the domain entities, the request and response payloads, the props of a component:

export interface CartTotals {
  subtotal: number;
  tax: number;
  deliveryFee: number;
  total: number;
  itemCount: number;
}

type for everything a shape cannot express — the unions, the aliases, the derived types:

export type OrderStatus =
  | 'PENDING_PAYMENT'
  | 'PAID'
  | 'PREPARING'
  | 'COMPLETED'
  | 'CANCELLED';

Follow that and you will rarely have to think about it: if you are describing an object and giving it a name, use interface; otherwise you have no choice anyway.

Neither one validates anything

Worth restating in this lesson specifically, because the word "interface" carries baggage from languages where it means something enforced at runtime.

An interface in TypeScript is not a contract a value signs. It is a description the compiler checks your code against, and it is erased before anything runs. There is no reflection, no instanceof Product, and no way to ask at runtime whether a value matches one. If you need that, you need a schema library — the same conclusion lesson 1 reached about API responses.

The one thing that is not a difference

A myth worth killing: interface is not more "object-oriented" and does not imply inheritance or a class. A class can implements either one:

type Formatter = { transform(value: number): string };

class MoneyFormatter implements Formatter {
  transform(value: number): string {
    return formatMoney(value);
  }
}

That compiles. implements takes any object type, and this remains a structural language regardless of which keyword declared the shape.

Angular's own code makes the point in the other direction. PipeTransform is an interface, and the app implements it:

@Pipe({ name: 'money' })
export class MoneyPipe implements PipeTransform {
  transform(value: number | null | undefined): string {
    return formatMoney(value ?? 0);
  }
}

The implements clause there is a compile-time assertion, nothing more. It checks the class has a matching transform and then disappears. Angular finds the pipe through the @Pipe decorator, not through the interface — see lesson 19.

Where the argument actually shows up

Three situations account for nearly every real instance of this question.

Component props. Both work, and the React ecosystem is genuinely split. The pizza app uses an interface:

interface Props {
  product: Product;
  onSelect: (product: Product) => void;
}

The one thing that occasionally decides it: if your props are a union — a component that takes either a href or an onClick, say — you need a type. Some teams use type everywhere for props purely so the rule never has an exception.

Extending third-party types. Interface, because merging is the only mechanism. Covered above.

Public API of a library. Interface, and here the merging behaviour flips from hazard to feature: your consumers can extend your types without you having to anticipate every extension. If you export an alias, they cannot.

Everywhere else, the decision is worth about ten seconds of anyone's time.

A note on satisfies

Both work with satisfies, which is worth knowing because it removes one bad reason to pick a type. You do not need an alias to check an object against a shape without adopting it:

const totals = {
  subtotal: 21.98,
  tax: 1.87,
  deliveryFee: 3.99,
  total: 27.84,
  itemCount: 2,
} satisfies CartTotals;

CartTotals is an interface, and the operator is perfectly happy with it. See lesson 16.

The short answer

You want toUse
name an object shapeinterface
name a union, primitive or tupletype — no choice
extend a type from a libraryinterface — no choice
derive a type from anothertype
define props for a componenteither; be consistent

Whichever you pick, pick it consistently. The cost of the wrong choice here is very low; the cost of a codebase where the two are mixed at random is a small tax on every file you read.

If your team wants the decision made once and enforced, the typescript-eslint rule consistent-type-definitions does it, and takes either answer as its setting. That is usually a better use of the argument than having it again in each review — the two constructs are close enough that the consistency is worth more than the choice.

One last practical note for reading other people's code: a project that uses type everywhere is not doing anything unusual, and neither is one that uses interface everywhere. Both conventions are common and both are defensible. Where you should look twice is a declaration that had to be one or the other — a union, or a merge into a library type — because that one is telling you something about the design rather than about the house style.

Next

Unions and Intersections — the two ways of combining types, and why only one of them is used constantly.