DEV Community

Alvin Bellero
Alvin Bellero

Posted on

🧠 TypeScript: Type vs Interface β€” Stop Guessing, Start Knowing

If you have written TypeScript for more than a week, someone has asked you this question. Or you have asked it yourself while staring at a blank file wondering why the language gives you two ways to do what looks like the same thing.

Should I use type or interface?

The Stack Overflow answers are all over the place. The blog posts are mostly outdated. The official docs say "use interface by default" and then immediately show you a dozen things interface cannot do. And your senior colleague says "we just use types for everything" while the other senior colleague says "always interfaces for objects, types for everything else."

Both of them are right. Both of them are incomplete. Let me give you the full picture.

Confused math lady

πŸ“Œ This article is written against TypeScript 5.7 (the current stable release as of early 2026). TypeScript 6.0 is in beta as the final JavaScript-based release, and TypeScript 7 (rewritten in Go) is in active preview. Notes on what changes with 7 are included where relevant.

πŸ“– TypeScript Blog β€” devblogs.microsoft.com


First, What Do They Actually Do?

Both type and interface let you describe the shape of a value. In their most basic form, they look almost identical.

// With interface
interface User {
  id: string;
  name: string;
  email: string;
}

// With type
type User = {
  id: string;
  name: string;
  email: string;
};
Enter fullscreen mode Exit fullscreen mode

For this use case β€” describing the shape of an object β€” they are functionally equivalent. The compiled JavaScript output is identical. The editor behavior is the same. The error messages are nearly the same.

So why does the choice matter? Because they diverge in meaningful ways the moment your types get more complex.


What interface Can Do That type Cannot πŸ—οΈ

Declaration Merging

This is the most important feature exclusive to interface and the one that most developers either love or get burned by.

If you declare the same interface name twice in the same scope, TypeScript merges them automatically.

interface Window {
  userLocale: string;
}

interface Window {
  analyticsReady: boolean;
}

// TypeScript sees this as:
// interface Window {
//   userLocale: string;
//   analyticsReady: boolean;
// }
Enter fullscreen mode Exit fullscreen mode

This is how you extend third party library types without modifying their source. If you have ever added custom properties to Express's Request object or extended global browser types, this is how it works.

// Extending Express Request in a .d.ts file
declare global {
  namespace Express {
    interface Request {
      currentUser?: User;
      requestId: string;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

With type this is impossible. Declaring the same type name twice is a compile error.

πŸ“– Declaration Merging β€” typescriptlang.org

Implements in Classes

Classes can implement interfaces directly, and the compiler enforces the contract cleanly.

interface Repository<T> {
  findById(id: string): Promise<T | null>;
  save(entity: T): Promise<T>;
  delete(id: string): Promise<void>;
}

class UserRepository implements Repository<User> {
  async findById(id: string) { /* ... */ }
  async save(user: User) { /* ... */ }
  async delete(id: string) { /* ... */ }
}
Enter fullscreen mode Exit fullscreen mode

You can technically implement a type alias too, but only when it represents an object shape. The moment the type is a union, the compiler refuses.

type PrimaryKey = { key: number } | { key: string };

// Error β€” cannot implement a union type
class Entity implements PrimaryKey {
  key = 1;
}
Enter fullscreen mode Exit fullscreen mode

πŸ“– Interfaces β€” typescriptlang.org


What type Can Do That interface Cannot 🎯

This is where type genuinely pulls ahead. Everything listed here is impossible with interface.

Union Types

type Status = 'pending' | 'active' | 'suspended' | 'deleted';

type ApiResponse<T> =
  | { success: true; data: T }
  | { success: false; error: string };
Enter fullscreen mode Exit fullscreen mode

Interfaces cannot express OR. Only AND. If you need to say "this is one of these things," you need type.

Discriminated Unions (The Pattern You Should Be Using More)

This is one of the most powerful patterns in TypeScript and it requires type.

type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'rectangle'; width: number; height: number }
  | { kind: 'triangle'; base: number; height: number };

function getArea(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2;
    case 'rectangle':
      return shape.width * shape.height;
    case 'triangle':
      return (shape.base * shape.height) / 2;
  }
}
Enter fullscreen mode Exit fullscreen mode

The compiler knows exactly which properties are available in each branch. You cannot accidentally access shape.radius in the rectangle branch. This is making impossible states unrepresentable, and it is one of the best things TypeScript can do for you.

πŸ“– Discriminated Unions β€” typescriptlang.org

Conditional Types

type IsArray<T> = T extends any[] ? true : false;

type Unwrap<T> = T extends Promise<infer U> ? U : T;

// Usage
type A = Unwrap<Promise<string>>; // string
type B = Unwrap<number>;          // number
Enter fullscreen mode Exit fullscreen mode

This is the foundation of every utility type in TypeScript. Partial, Required, ReturnType, Awaited β€” all built with conditional types. None of it is possible with interface.

πŸ“– Conditional Types β€” typescriptlang.org

Mapped Types

// Make all properties optional and readonly
type Snapshot<T> = {
  readonly [K in keyof T]?: T[K];
};

// Extract only string properties from an object type
type StringProperties<T> = {
  [K in keyof T as T[K] extends string ? K : never]: T[K];
};

type User = { id: number; name: string; email: string; age: number };
type UserStrings = StringProperties<User>;
// Result: { name: string; email: string }
Enter fullscreen mode Exit fullscreen mode

πŸ“– Mapped Types β€” typescriptlang.org

Template Literal Types

type EventName = 'click' | 'focus' | 'blur';
type Handler = `on${Capitalize<EventName>}`;
// Result: 'onClick' | 'onFocus' | 'onBlur'

type CSSProperty = 'margin' | 'padding';
type CSSDirection = 'Top' | 'Right' | 'Bottom' | 'Left';
type CSSLonghand = `${CSSProperty}${CSSDirection}`;
// 'marginTop' | 'marginRight' | ... | 'paddingLeft'
Enter fullscreen mode Exit fullscreen mode

πŸ“– Template Literal Types β€” typescriptlang.org

Tuple Types

type Pair<A, B> = [A, B];
type RGB = [number, number, number];
type HTTPMethod = ['GET' | 'POST' | 'PUT' | 'DELETE', string];
Enter fullscreen mode Exit fullscreen mode

The Performance Question. Honestly Answered. ⚑

This comes up in every conversation and the answer is nuanced so I am going to give it the space it deserves.

Interfaces with extends are cached. Type intersections are not.

When TypeScript evaluates a type intersection (&), it recomputes the merged type every time it encounters it. When TypeScript evaluates an interface that extends another, it checks the named interface reference, which it has already cached.

// Recomputed on every use β€” not cached
type AdminUser = User & { permissions: string[] } & { department: string };

// Cached by name β€” more efficient for repeated use
interface AdminUser extends User {
  permissions: string[];
  department: string;
}
Enter fullscreen mode Exit fullscreen mode

A real benchmark across 10,000 intersection types vs 10,000 extended interfaces showed the interface variant compiling in 1 minute 26 seconds versus 2 minutes 33 seconds for the intersection variant. That is almost half the compile time.

πŸ“– TypeScript Performance Wiki β€” github.com/microsoft/TypeScript
πŸ“– Benchmark β€” mykytam.com

But context matters

For most apps this difference will not show up in your daily work. Where it genuinely matters is in large monorepos with hundreds of deeply composed types, running full compilation in CI. If your CI builds are creeping up toward ten minutes, auditing your intersection types is a legitimate optimization worth doing.

For local development, the TypeScript language server evaluates types per file rather than the whole project, so the day to day impact is much smaller.

πŸ“– LogRocket β€” Types vs Interfaces Performance

TypeScript 7 changes the picture

TypeScript 7 is a complete rewrite of the compiler in Go, targeting up to 10 times faster compile times across the board. Compile times should shrink from minutes to seconds, and language service operations like completions, renames, and refactorings become more responsive. When TypeScript 7 becomes the default, the performance gap between type and interface narrows significantly because the baseline is so much faster. The right choice for the compile time benefit today is interface extends over & intersections β€” but do not stress about it if you are on small to medium projects.

πŸ“– Progress on TypeScript 7 β€” devblogs.microsoft.com
πŸ“– TypeScript 7 Native Preview β€” developer.microsoft.com


The Declaration Merging Trap πŸͺ€

This is the caveat that Matt Pocock at Total TypeScript correctly calls out as the main reason to be careful with interfaces as your default.

If you accidentally declare the same interface name twice in the same scope, TypeScript silently merges them. No error. No warning. Just a type that now has extra properties you did not intend.

// Somewhere in your codebase
interface User {
  id: string;
  name: string;
}

// Somewhere else β€” maybe a different file, maybe a library you imported
interface User {
  internalFlag: boolean; // silently merged in
}

// Now User requires internalFlag everywhere. Good luck debugging that.
const user: User = { id: '1', name: 'Jane' };
// Error: Property 'internalFlag' is missing
Enter fullscreen mode Exit fullscreen mode

With type, the second declaration is an immediate compiler error. You find the problem at the point of declaration, not at the point of use.

πŸ“– Total TypeScript β€” type vs interface


Tips and Tricks That Actually Matter in Production πŸ› οΈ

Use discriminated unions to make invalid states impossible

Stop using optional properties to represent different states of the same object. Model each state explicitly.

// The fragile way β€” optional properties everywhere
type Order = {
  status: 'pending' | 'shipped' | 'delivered';
  trackingNumber?: string; // only when shipped
  deliveredAt?: Date;      // only when delivered
};

// The correct way β€” each state is its own type
type Order =
  | { status: 'pending' }
  | { status: 'shipped'; trackingNumber: string }
  | { status: 'delivered'; trackingNumber: string; deliveredAt: Date };

// The compiler now enforces what belongs where
function handleOrder(order: Order) {
  if (order.status === 'shipped') {
    console.log(order.trackingNumber); // always defined here, no ?. needed
  }
}
Enter fullscreen mode Exit fullscreen mode

πŸ“– Narrowing β€” typescriptlang.org

Use satisfies to get both inference and validation

The satisfies operator (stable since TypeScript 4.9) lets you validate that a value matches a type while keeping the most specific inferred type.

type Palette = Record<string, string | [number, number, number]>;

// Without satisfies β€” TypeScript infers the wide type
const colors = {
  red: [255, 0, 0],
  green: '#00ff00',
} satisfies Palette;

// Now colors.red is inferred as [number, number, number], not string | [number, number, number]
// And colors.green is string, not string | [number, number, number]
colors.red.at(0); // works β€” TypeScript knows it's a tuple
colors.green.toUpperCase(); // works β€” TypeScript knows it's a string
Enter fullscreen mode Exit fullscreen mode

Without satisfies you would have to choose between type safety and useful inference. Now you get both.

πŸ“– satisfies operator β€” typescriptlang.org

Prefer interface extends over & for object composition

When you are combining object types, extends is more readable and more performant than &.

// Less readable, less performant in large codebases
type AdminUser = User & { permissions: string[] } & Timestamps;

// Better
interface AdminUser extends User, Timestamps {
  permissions: string[];
}
Enter fullscreen mode Exit fullscreen mode

πŸ“– TypeScript Performance β€” github.com/microsoft/TypeScript

Use const assertions for literal types

When you want TypeScript to infer the narrowest possible type from a value, as const is the move.

// Without as const β€” TypeScript infers string[]
const directions = ['north', 'south', 'east', 'west'];

// With as const β€” TypeScript infers readonly ['north', 'south', 'east', 'west']
const directions = ['north', 'south', 'east', 'west'] as const;
type Direction = typeof directions[number];
// Result: 'north' | 'south' | 'east' | 'west'

// Now adding an invalid direction is a compile error
function move(dir: Direction) { /* ... */ }
move('up'); // Error β€” not in the union
Enter fullscreen mode Exit fullscreen mode

πŸ“– const assertions β€” typescriptlang.org

Use template literal types to keep string patterns safe

If your codebase uses string patterns like route paths, event names, or CSS class names, template literal types let you enforce them at compile time.

type Route = `/api/${string}`;
type VersionedRoute = `/api/v${number}/${string}`;

function fetchData(route: Route) { /* ... */ }

fetchData('/api/users');         // fine
fetchData('/api/v2/products');   // fine
fetchData('/users');             // Error β€” does not start with /api/
Enter fullscreen mode Exit fullscreen mode

πŸ“– Template Literal Types β€” typescriptlang.org

Use infer for type level parsing

When you need to extract parts of a type programmatically, infer is the right tool.

// Extract the return type of any async function
type Awaited<T> = T extends Promise<infer R> ? R : T;

// Extract the element type of any array
type ElementType<T> = T extends (infer E)[] ? E : never;

// Extract parameter types of a function
type FirstParam<T> = T extends (first: infer F, ...rest: any[]) => any ? F : never;

type A = ElementType<string[]>; // string
type B = FirstParam<(name: string, age: number) => void>; // string
Enter fullscreen mode Exit fullscreen mode

πŸ“– Inferring within Conditional Types β€” typescriptlang.org


The Decision Framework πŸ—‚οΈ

Here is the clearest way I can give you to make the call.

Use interface when:

  • You are defining a contract that classes will implement
  • You are extending or augmenting third party library types
  • You are writing a library and want consumers to be able to extend your types via declaration merging
  • You are building deep object hierarchies and composition matters for compile performance

Use type when:

  • You need a union (|)
  • You need a conditional type
  • You need a mapped type or template literal type
  • You are defining tuples
  • You are defining function signatures (the syntax is cleaner)
  • You do not want accidental declaration merging to be possible
  • You are using functional patterns where each thing is its own distinct type

In practice: most experienced TypeScript teams land on one of two camps. Either "default to interface for objects, use type for everything else" or "default to type for everything, use interface only when declaration merging is needed." Both are defensible. The worst thing you can do is mix them randomly without a team decision.

Whatever you choose, write it down and enforce it with ESLint.

// .eslintrc β€” enforce your team's choice
{
  "rules": {
    "@typescript-eslint/consistent-type-definitions": ["error", "type"]
    // or: ["error", "interface"]
  }
}
Enter fullscreen mode Exit fullscreen mode

πŸ“– consistent-type-definitions β€” typescript-eslint.io


TypeScript 7 and What Is Coming πŸ”­

Two things worth knowing about the near future.

TypeScript 6.0 is the last release on the old JavaScript compiler codebase. It is being framed as a bridge release to prepare the ecosystem for TypeScript 7. TypeScript 6.0 will deprecate features to align with 7.0, and will be highly compatible in terms of type checking behavior.

TypeScript 7 features like --incremental, project reference support, and --build mode are all ported over and working in the native Go compiler. Most projects can now try the native preview with minimal changes.

What this means for the type vs interface question: the syntax is not changing. Your existing code works. The big shift is that the performance argument in favor of interface extends over & intersections becomes less urgent as the Go compiler makes everything faster. But correctness and clarity remain the right reasons to pick one over the other regardless of the compiler speed.

πŸ“– TypeScript 6.0 Beta β€” devblogs.microsoft.com
πŸ“– Progress on TypeScript 7 β€” devblogs.microsoft.com


The Short Version πŸ‘‹

interface gives you declaration merging, clean class contracts, and slightly better compile performance for object composition through extends. Use it when you want extensibility to be explicit and intentional, or when you are writing library code that consumers will augment.

type gives you unions, conditional types, mapped types, template literals, tuples, and protection against accidental merging. Use it for everything that goes beyond describing a simple object shape.

For performance: prefer interface extends over type & intersections when composing many object types, especially in large codebases. The satisfies operator is your friend when you need both type safety and narrow inference. And discriminated unions are the pattern you should be reaching for every time you see optional properties that only make sense in certain states.

Pick a convention. Lint for it. Stop relitigating it every PR review.

Mic drop


Which camp are you in β€” team type or team interface? Drop it in the comments. I want to know if I will be starting a war.

Top comments (4)

Collapse
 
htho profile image
Hauke T.

I like your "Decision Framework".

The fallback in my projects is:

  1. Use interface if you describe something that will be implemented by a class.
  2. Use type for plain objects.

Also use ; to separate properties in interfaces and , in types because this is the context.

Collapse
 
shiftescape profile image
Alvin Bellero

Awesome! Solid rules to live by πŸ™Œ

Collapse
 
pmoati profile image
Pierre Moati

Good breakdown ! πŸ‘

This is exactly why I default to type for most things in practice 🀩

As soon as you need discriminated unions or Result types for error handling, interface can't express that.

And once you start composing these patterns together, sticking with type everywhere keeps things consistent and avoids the mental overhead of switching between the two.

Collapse
 
shiftescape profile image
Alvin Bellero

Thanks! And yes! once discriminated unions and Result types enter the picture, type just becomes the natural default and there is no looking back. Consistency wins every time. 😊