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
typeorinterface?
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.
π 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.
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;
};
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;
// }
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;
}
}
}
With type this is impossible. Declaring the same type name twice is a compile error.
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) { /* ... */ }
}
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;
}
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 };
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;
}
}
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.
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
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.
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 }
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'
Tuple Types
type Pair<A, B> = [A, B];
type RGB = [number, number, number];
type HTTPMethod = ['GET' | 'POST' | 'PUT' | 'DELETE', string];
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;
}
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.
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
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.
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
}
}
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
Without satisfies you would have to choose between type safety and useful inference. Now you get both.
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[];
}
π 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
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/
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
π 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"]
}
}
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.
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)
I like your "Decision Framework".
The fallback in my projects is:
interfaceif you describe something that will be implemented by a class.typefor plain objects.Also use
;to separate properties ininterfaces and,intypes because this is the context.Awesome! Solid rules to live by π
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.
Thanks! And yes! once discriminated unions and Result types enter the picture,
typejust becomes the natural default and there is no looking back. Consistency wins every time. π