Differences between type and interface in TypeScript
type and interface in TypeScript both describe object shapes, but with one key behavioral difference: interface merges repeated declarations, while type creates a fixed alias that works with unions, primitives, and mapped types.
Theory
TL;DR
interface= Google Doc (you can add properties later);type= sealed PDF (fixed at creation)- Main difference:
interfacemerges repeated declarations automatically;typethrows an error on redeclaration - Only
typesupports union types (A | B), primitives, tuples, and mapped types - Need object contracts, class inheritance, or library augmentation?
interface. Need unions, discriminated unions, or type transformations?type - Both are fully erased at compile time. JavaScript sees neither
Quick example
// interface: repeated declarations merge
interface User {
name: string;
}
interface User {
age: number; // merges with the declaration above
}
// Result: User = { name: string; age: number }
// type: cannot be redeclared
type Product = { id: number };
// type Product = { name: string }; // ERROR: Duplicate identifier 'Product'
// Only type supports union types
type Status = "active" | "inactive" | "pending";
type Result = User | Product;Declare User twice with interface and TypeScript combines both into one. Do the same with type and the compiler refuses.
Key difference
interface is for object shapes. It supports declaration merging, where two declarations with the same name combine into one, and inheritance via extends. type is an alias for any structure: primitives, union types, intersections, tuples. But it cannot be merged or redeclared. Once you write type X = ..., that definition is final.
When to use
interface: object contracts for classes (implements), API response shapes, React props that other components might extend, library augmentation where consumers need to add propertiestype: union types ("success" | "error"), intersections (A & B), primitive aliases (type ID = string | number), discriminated unions for state machines, mapped types, conditional types
Many teams default to interface for objects and type for everything else. Others use type everywhere. Both approaches work. The biggest friction I've seen: someone ships a public library with type for contracts that consumers need to extend. That choice ripples through every downstream project.
Comparison table
| Feature | interface | type |
|---|---|---|
| Object shape | Yes | Yes |
| Union types | No | Yes |
| Intersection | Via extends | Via & |
| Declaration merging | Yes | No |
| Primitives / tuples | No | Yes |
| Function signatures | Works, not conventional | Yes |
| Mapped types | No | Yes |
| Conditional types | No | Yes |
implements in classes | Yes (conventional) | Yes (works) |
| Best for | Object contracts, class APIs, library augmentation | Unions, primitives, type transformations |
How the compiler handles this
TypeScript treats interface declarations as open containers. When it sees two declarations with the same name, it merges their properties into one type. type aliases are direct substitutions: the compiler replaces each reference with its definition. Neither survives compilation. Both are fully erased when TypeScript outputs JavaScript.
Common mistakes
Trying to create a union with interface
// WRONG - syntax error
// interface Result = User | Product;
// RIGHT
type Result = User | Product;interface cannot express "either A or B". That belongs to type.
Expecting type to merge like interface
// WRONG
type Config = { apiUrl: string };
type Config = { timeout: number }; // ERROR: Duplicate identifier
// RIGHT - use interface when merging is needed
interface Config {
apiUrl: string;
}
interface Config {
timeout: number;
} // merges automatically
const config: Config = { apiUrl: "...", timeout: 5000 }; // worksUsing optional properties instead of a discriminated union
// WRONG - allows impossible state combinations
interface State {
status: "idle" | "loading" | "success" | "error";
data?: User;
error?: string; // nothing prevents data and error coexisting
}
// RIGHT - discriminated union makes invalid states unrepresentable
type State =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: User }
| { status: "error"; error: string };This pattern requires type. No equivalent exists with interface.
Using & instead of extends for object inheritance
// Works, but hides the hierarchy
type AdvancedConfig = BaseConfig & { ssl: boolean };
// Clearer when the relationship matters
interface AdvancedConfig extends BaseConfig {
ssl: boolean;
}There is a real behavioral difference here. extends catches property conflicts immediately with a compile error. With &, a conflicting property type resolves to never instead, a silent dead end that produces no error until you try to use the property.
Using interface for function type signatures
// Works, but uncommon
interface Callback {
(error: Error | null, data: string): void;
}
// Cleaner and standard
type Callback = (error: Error | null, data: string) => void;Real-world usage
- React: props interfaces extend
React.PropsWithChildren; component state uses discriminatedtypeunions - Redux: action types use discriminated
typeunions for type-safe reducers; store shape usesinterface - Express: middleware adds properties to
Requestviainterfacedeclaration merging; route handlers usetypefor response unions - NestJS: DTOs use
interfacefor inheritance; API responses usetypefor discriminated unions - Library authors: all public contracts exported as
interfaceso consumers can augment them without modifying source
Follow-up questions
Q: Can you use type in a class implements statement?
A: Yes. It works structurally. But interface is the conventional choice because it signals intent and supports declaration merging. Using type with implements is not wrong, just less standard.
Q: What happens when you extend an interface with a conflicting property?
A: TypeScript reports the error immediately at the extends statement. With & in type, conflicting property types resolve to never instead. The practical difference: never is silent, extends gives you an error right at the source.
Q: How does declaration merging work with generics?
A: Generic parameters must match exactly across merged declarations. Two interface Box<T> declarations merge fine. But interface Box<T, U> and interface Box<T> are treated as different interfaces and will not merge.
Q: Why do Express, NestJS, and Fastify use interface for their public types?
A: Because consumers need to extend them. Express middleware adds custom properties to Request by redeclaring the interface locally. If Express exported type Request = { ... }, that augmentation would not compile.
Q: (Senior) How would you design a public type system where consumers can extend types without touching your source?
A: Export all public contracts as interface. This lets consumers use declaration merging in their own files by adding properties through redeclaration. If you used type, they are limited to exactly what you shipped. This is exactly how Express Request augmentation works.
Examples
Object extension with interface
interface BaseProps {
className?: string;
children: React.ReactNode;
}
interface ButtonProps extends BaseProps {
onClick: () => void;
variant: "primary" | "secondary";
}
const Button: React.FC<ButtonProps> = ({
onClick,
variant,
children,
className,
}) => (
<button onClick={onClick} className={`btn btn-${variant} ${className}`}>
{children}
</button>
);ButtonProps picks up className and children from BaseProps automatically. The extends keyword makes the relationship explicit and catches conflicts at compile time.
Discriminated union with type (API state machine)
type ApiResponse =
| { status: "success"; data: User[] }
| { status: "error"; error: string }
| { status: "loading" };
function handleResponse(response: ApiResponse) {
switch (response.status) {
case "success":
console.log(response.data); // TypeScript knows data exists here
break;
case "error":
console.error(response.error); // TypeScript knows error exists here
break;
case "loading":
console.log("Waiting...");
break;
}
}TypeScript uses the status field to narrow the type in each branch. Add a new status variant and forget to handle it - TypeScript catches the gap.
Declaration merging for library augmentation
declare global {
namespace Express {
interface Request {
user?: {
id: string;
role: "admin" | "viewer";
};
}
}
}
app.get("/profile", (req, res) => {
if (req.user?.role === "admin") {
// TypeScript knows user.role has exactly two possible values
}
});This works because Express exports Request as an interface. The declare global block merges additions into the existing definition. With type, this does not compile.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.