Skip to main content

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: interface merges repeated declarations automatically; type throws an error on redeclaration
  • Only type supports 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

typescript
// 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 properties
  • type: 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

Featureinterfacetype
Object shapeYesYes
Union typesNoYes
IntersectionVia extendsVia &
Declaration mergingYesNo
Primitives / tuplesNoYes
Function signaturesWorks, not conventionalYes
Mapped typesNoYes
Conditional typesNoYes
implements in classesYes (conventional)Yes (works)
Best forObject contracts, class APIs, library augmentationUnions, 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

typescript
// 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

typescript
// 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 }; // works

Using optional properties instead of a discriminated union

typescript
// 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

typescript
// 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

typescript
// 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 discriminated type unions
  • Redux: action types use discriminated type unions for type-safe reducers; store shape uses interface
  • Express: middleware adds properties to Request via interface declaration merging; route handlers use type for response unions
  • NestJS: DTOs use interface for inheritance; API responses use type for discriminated unions
  • Library authors: all public contracts exported as interface so 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

typescript
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)

typescript
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

typescript
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 ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?