type in frontend development. When I had AI write code, though, it reached for interface at every turn. That threw me, so I decided to sort out the difference between the two once and for all. Confession time: my understanding was on shaky ground. On the Next.js and React projects I'd worked on, everyone used type, so I used it too. That was the whole story.
Let me give my provisional conclusion up front. Use type unless you have a reason not to. Use interface when working with classes, or when something needs to be extended from outside. And above all, follow the project's policy.
A Provisional Conclusion
type is fine unless you have a reason otherwise. My reason is that it makes "type computation" easy: union types, intersection types, tuple types, conditional types. It also plays nicely with React Props definitions and utility types. In ordinary business application development, performance problems practically never come up. From the standpoint of readability and flexibility too, making type the baseline is the natural choice.
On the other hand, when designing in an OOP style with classes, or when external extension is needed, I use interface. It fits naturally with a class's implements, and if you're writing OOP it simply feels more intuitive. What's more, it supports declaration merging and module augmentation. Adding a field to Express's Request or extending styled-components' DefaultTheme — when you want to widen a type from an external library, interface is the only choice.
That said, the project's policy comes first. Large organizations like Google sometimes decide explicitly that "object types use interface." In team development, what counts is less which one you use than whether you use it consistently. That consistency is what lifts the readability and maintainability of the entire codebase.
Basic Definitions
To begin with, both can define an object type in much the same way. Withinterface, you write this.
interface User {
id: number;
name: string;
email: string;
}
With type, it looks like this.
type User = {
id: number;
name: string;
email: string;
};
They look almost identical. The differences hide in the details. They say God is in the details; with types, the traps move in right alongside.
Declaration Merging
First, aninterface declared multiple times under the same name is merged automatically.
interface User {
id: number;
name: string;
}
interface User {
email: string;
}
// The result is the same as the following
interface User {
id: number;
name: string;
email: string;
}
Frankly, this behavior is a little alarming. Declare an interface with the same name in some distant file, and properties quietly sprout without you ever knowing. A type, by contrast, throws an error if declared twice under the same name.
type User = {
id: number;
name: string;
};
// Error: Duplicate identifier 'User'
type User = {
email: string;
};
Inheritance and Intersection Types
Next comes inheritance. Aninterface can extend another interface with extends, and it can extend a type as well.
interface Animal {
name: string;
}
interface Dog extends Animal {
breed: string;
}
// Can also extend from a type
type Cat = {
meow: () => void;
};
interface PersianCat extends Cat {
furColor: string;
}
A type has no extends keyword, but an intersection type (&) achieves much the same thing.
type Animal = {
name: string;
};
type Dog = Animal & {
breed: string;
};
When you try to override a property during inheritance, though, the two part ways. With interface, the derived type can narrow it to something more specific.
interface Animal {
name: string | number;
}
interface Dog extends Animal {
name: string; // Can be changed to a more specific type
}
With a type intersection, however, you get not an override but a product of types. string & (string | number) is string, so at first glance it works. Specify a contradictory type, though, and you end up with never.
type Animal = {
name: string | number;
};
type Dog = Animal & {
name: string; // With an intersection type, string & (string | number) = string
};
// However, specifying a contradictory type results in never
type Cat = Animal & {
name: boolean; // boolean & (string | number) = never
};
Even so, with Generics, type can override cleanly too. Strip the relevant keys with Omit first, then intersect.
type Override<T, U> = Omit<T, keyof U> & U;
type Animal = {
name: string | number;
};
type Dog = Override<Animal, { name: string }>;
// => { name: string } // Overwritten as expected
type Cat = Override<Animal, { name: boolean }>;
// => { name: boolean } // Not never; simply overwritten with boolean
What Only type Can Do
Conversely, there's plenty onlytype can do. The classic example is aliasing union types and primitive types. Try that with interface and you get an error.
// Possible with type
type Status = "loading" | "success" | "error";
type ID = string | number;
type UserId = string;
// Not possible with interface
// interface Status = "loading" | "success" | "error"; // Error
Advanced type operations like conditional types and mapped types are also type's territory.
// Conditional type
type NonNullable<T> = T extends null | undefined ? never : T;
// Mapped type
type Readonly<T> = {
readonly [P in keyof T]: T[P];
};
// These are not possible with interface
One more example for mapped types. Utility types like Readonly and Partial are built with exactly this mechanism.
// Only possible with type
type Readonly<T> = {
readonly [P in keyof T]: T[P];
};
type Partial<T> = {
[P in keyof T]?: T[P];
};
// Not possible with interface
// interface ReadonlyInterface<T> = {
// readonly [P in keyof T]: T[P]; // Error
// };
Tuple types, too, can only be defined cleanly with type. You can build something similar with an interface by listing indices and a length. But it would take a rare eccentric to write that for fun.
// Possible with type
type Coordinates = [number, number];
type RGB = [number, number, number];
// Not directly possible with interface (defining it as an array-like is possible)
interface CoordinatesInterface {
0: number;
1: number;
length: 2;
}
Where the Google Style Guide Stands
So how does the industry split them? The Google Style Guide recommendstype for primitive, union, and tuple types. For object type definitions, it's interface. The reasoning given is this passage.
Since the two are nearly equivalent, pick one to avoid inconsistency. On top of that, there are technical reasons to favorThese forms are nearly equivalent, so under the principle of just choosing one out of two forms to prevent variation, we should choose one. Additionally, there are also interesting technical reasons to prefer interface. That page quotes the TypeScript team lead: Honestly, my take is that it should really just be interfaces for anything that they can model. There is no benefit to type aliases when there are so many issues around display/perf. https://google.github.io/styleguide/tsguide.html#prefer-interfaces
interface. That's the argument. Following this policy, you'd split them like this.
// Example using type
type Status = "loading" | "success" | "error";
type Point = [number, number];
type ID = string;
// Example using interface
interface User {
id: ID;
name: string;
status: Status;
}
Organized along those lines, you use interface first of all when defining the shape of an object.
interface ApiResponse {
data: any;
status: number;
message: string;
}
Next, when you want to leave room for extension in library or API type definitions. An interface declared on the library side can be widened on the user side by a declaration with the same name.
// On the library side
interface Config {
apiUrl: string;
}
// Extended on the user side
interface Config {
timeout: number;
}
And when a class is going to implement it, interface is the natural fit.
interface Drawable {
draw(): void;
}
class Circle implements Drawable {
draw() {
// Implementation
}
}
Conversely, you use type when creating aliases for unions, function types, and the like.
type Theme = "light" | "dark";
type EventHandler = (event: Event) => void;
Complex type operations such as conditional and mapped types also call for type.
type Partial<T> = {
[P in keyof T]?: T[P];
};
Tuple types are the same.
type Point = [number, number];
Finally, composing a type by bolting fields onto an existing one with an intersection is another job for type.
type UserWithTimestamps = User & {
createdAt: Date;
updatedAt: Date;
};
Inside the Compiler
The code below illustrates declarations and checking conceptually; it is not an excerpt from a particular TypeScript compiler version. Type aliases also have symbols, and actual resolution and caching depend on the type construction. A type alias representing an appropriate object type can also be used in a class’simplements clause.
From here on, let's peek inside the TypeScript compiler. The compiler processes interface and type in different ways to begin with. An interface is registered in the symbol table as a named symbol. During type checking, it's referenced by that name.
interface User {
name: string;
age: number;
}
// Stored internally by the compiler as a named symbol
// - Registered in the symbol table as "User"
// - Referenced by name during type checking
// - The type name is shown in error messages
A type, meanwhile, is processed as a type alias, and its structure is expanded as needed. For complex types, it may be expanded all the way.
type User = {
name: string;
age: number;
};
// Expanded structurally inside the compiler
// - Processed as a type alias
// - The type is expanded as needed
// - For complex types, it may be fully expanded
This difference shows up in type-checking performance as well. An interface inheritance chain is processed efficiently. That's because each interface is managed as an independent symbol.
interface BaseConfig {
apiUrl: string;
}
interface UserConfig extends BaseConfig {
userId: string;
}
interface AdminConfig extends BaseConfig {
adminKey: string;
}
// Inheritance chains are processed efficiently
// Each interface is managed as an independent symbol
Build the same structure with type, on the other hand, and intersection types have to be computed. Type composition runs every time they're used.
type BaseConfig = {
apiUrl: string;
};
type UserConfig = BaseConfig & {
userId: string;
};
type AdminConfig = BaseConfig & {
adminKey: string;
};
// Intersection types need to be computed
// Type composition happens at the point of use
Error messages differ too. For a simple object type, whether interface or type, the type name shows up as is.
interface User {
name: string;
age: number;
}
const user: User = { name: "Alice" };
// Error: Property 'age' is missing in type '{ name: string; }'
// but required in type 'User'.
Stack up intersections with type, however, and you get the expanded structure instead of a name. Three pieces are still readable. Stack ten, and any will to read it evaporates.
type User = {
name: string;
age: number;
};
const user: User = { name: "Alice" };
// Error: Property 'age' is missing in type '{ name: string; }'
// but required in type 'User'.
// For a complex type
type ComplexUser = {
name: string;
} & {
age: number;
} & {
email: string;
};
const complexUser: ComplexUser = { name: "Alice" };
// Error: Type '{ name: string; }' is missing the following properties
// from type '{ name: string; } & { age: number; } & { email: string; }':
// age, email
Compile Time and Memory
In large projects, these differences start to affect compile time. Aninterface built up with extends is fast. A type assembled from intersections is somewhat heavier.
// interface - fast
interface ApiResponse<T> {
data: T;
status: number;
}
interface UserResponse extends ApiResponse<User> {
user: User;
}
// type - somewhat heavier processing
type ApiResponse<T> = {
data: T;
status: number;
};
type UserResponse = ApiResponse<User> & {
user: User;
};
For checking performance, a useful distinction is interface inheritance versus compositions of intersection types. TypeScript’s Performance guide describes advantages of interfaces for object composition, including caching relationships between types. This does not mean type aliases receive no caching at all. A simple alias and a complex intersection need to be considered separately.
interface Config {
url: string;
}
// An interface with the same name is cached once resolved
// Declaration merging is also handled efficiently
interface Config {
timeout: number;
}
// The final merged type is computed only once
A type intersection, by contrast, must be computed every time. The cost creeps up as nesting deepens.
type Config = {
url: string;
};
type ExtendedConfig = Config & {
timeout: number;
};
// Intersection types must be computed every time
// Computation cost grows as nesting gets deeper
type DeeplyNestedConfig = ExtendedConfig & {
retries: number;
} & {
headers: Record<string, string>;
};
The draft recorded roughly 50ms for interface and 65ms for type: arithmetically, a difference of about 30%. Without the benchmark code, TypeScript version, and measurement conditions, those numbers cannot establish a general performance gap. If checking becomes a problem in a project, I would measure equivalent cases and identify which type constructions account for the cost.
// Example code for measurement
interface IUser {
id: string;
name: string;
email: string;
}
type TUser = {
id: string;
name: string;
email: string;
};
// When a large number of type checks are needed
// interface: ~50ms
// type: ~65ms
// (actual values depend on the size of the project)
Memory usage follows suit. interface is managed efficiently in the symbol table. type, for its part, may eat additional memory for type expansion.
Judged purely on performance, then, large projects should default to interface. Designs heavy on inheritance favor interface too, and library APIs are best designed with it. For complex type operations, you need the functionality in the first place, so type is fine.
Some may feel this clashes with my opening "use type unless you have a reason." But the gap only starts to matter at considerable scale. Rather than fussing over 15ms, you'd save more time by sparing one code review comment. For an ordinary business application, my read is that it's not worth losing sleep over.
How tsc Processes Them
Let's go a step further. What stages does the TypeScript compiler (tsc) go through forinterface and type?
First is lexical analysis. Suppose we have source code like this.
// Source code
interface User {
name: string;
}
type Status = "active" | "inactive";
It gets broken down into a sequence of tokens. The interface starts with INTERFACE_KEYWORD, the type with TYPE_KEYWORD.
// Result of tokenization
INTERFACE_KEYWORD, IDENTIFIER(User), LEFT_BRACE,
IDENTIFIER(name), COLON, STRING_KEYWORD, RIGHT_BRACE
TYPE_KEYWORD, IDENTIFIER(Status), EQUALS,
STRING_LITERAL("active"), PIPE, STRING_LITERAL("inactive")
Next, parsing builds an AST (Abstract Syntax Tree) from the tokens. Here the two become different kinds of nodes: InterfaceDeclaration for the interface, TypeAliasDeclaration for the type.
// AST node for interface
{
kind: SyntaxKind.InterfaceDeclaration,
name: { text: "User" },
members: [
{
kind: SyntaxKind.PropertySignature,
name: { text: "name" },
type: { kind: SyntaxKind.StringKeyword }
}
]
}
// AST node for type
{
kind: SyntaxKind.TypeAliasDeclaration,
name: { text: "Status" },
type: {
kind: SyntaxKind.UnionType,
types: [
{ kind: SyntaxKind.LiteralType, literal: { text: "active" } },
{ kind: SyntaxKind.LiteralType, literal: { text: "inactive" } }
]
}
}
Binding comes next, building the symbol table. The interface symbol holds members. The type symbol, by contrast, merely points to the aliased type.
// Symbol for interface
Symbol {
name: "User",
flags: SymbolFlags.Interface,
declarations: [InterfaceDeclaration],
members: Map {
"name" => Symbol { name: "name", type: StringType }
}
}
// Symbol for type
Symbol {
name: "Status",
flags: SymbolFlags.TypeAlias,
declarations: [TypeAliasDeclaration],
aliasedSymbol: UnionType
}
Then comes type checking. For an interface, the checker first resolves the User symbol and infers the type of the object literal. It then checks structural compatibility and finds the missing property.
interface User {
name: string;
age: number;
}
const user: User = { name: "Alice" }; // Error detected
// What the type checker does:
// 1. Resolve the User symbol
// 2. Infer the type of the object literal
// 3. Check structural compatibility
// 4. Detect the missing property (age)
For a type, it first expands the alias and resolves the actual structure. Then it compares that with the object literal. Look it up by the name tag, or open the box and inspect the contents. Put plainly, that's the difference.
type UserType = {
name: string;
age: number;
};
const user: UserType = { name: "Alice" }; // Error detected
// What the type checker does:
// 1. Expand the UserType alias
// 2. Resolve the actual type structure
// 3. Compare with the object literal
// 4. Detect the type mismatch
Last is conversion to JavaScript (Emit). What happens when we compile the following TypeScript?
// interface definition
interface User {
name: string;
age: number;
greet(): string;
}
// type definition
type Config = {
apiUrl: string;
timeout: number;
};
// Implementation
class UserImpl implements User {
constructor(public name: string, public age: number) {}
greet(): string {
return `Hello, I'm ${this.name}`;
}
}
const config: Config = {
apiUrl: "https://api.example.com",
timeout: 5000,
};
function processUser(user: User): void {
console.log(user.greet());
}
The result is almost anticlimactic. Both interface and type vanish without a trace, and only the implementation remains.
// interface is removed completely
// interface User { ... } → not included in the compiled output
// type is also removed completely
// type Config = { ... } → not included in the compiled output
// Only the implementation remains
class UserImpl {
constructor(name, age) {
this.name = name;
this.age = age;
}
greet() {
return `Hello, I'm ${this.name}`;
}
}
const config = {
apiUrl: "https://api.example.com",
timeout: 5000,
};
function processUser(user) {
console.log(user.greet());
}
Inside Declaration Merging and Inheritance
It's also worth seeing how declaration merging is handled inside the compiler. Say we have code that extendsWindow in two separate declarations.
interface Window {
customProperty: string;
}
interface Window {
anotherProperty: number;
}
// Usage example
window.customProperty = "test";
window.anotherProperty = 42;
The compiler creates the Window symbol on the first declaration. On the second, it finds that existing symbol and adds members to it. The result is a type with two new properties tacked onto all of the original Window's properties.
// 1. Process the first Interface Declaration
// Create the Symbol "Window" and add customProperty
// 2. Process the second Interface Declaration
// Find the existing "Window" Symbol
// Add anotherProperty to the existing members
// 3. The final merged type
interface Window {
customProperty: string;
anotherProperty: number;
// + all properties of the existing Window
}
After compilation, once again, only the implementation is left.
// Type information is removed, leaving only the implementation
window.customProperty = "test";
window.anotherProperty = 42;
Inheritance is handled along the same lines. Let's consider a Labrador class implementing Dog, which extends Animal.
interface Animal {
name: string;
makeSound(): string;
}
interface Dog extends Animal {
breed: string;
wagTail(): void;
}
class Labrador implements Dog {
constructor(public name: string, public breed: string) {}
makeSound(): string {
return "Woof!";
}
wagTail(): void {
console.log("Wagging tail happily");
}
}
The compiler first creates a symbol for Animal. It gives the Dog symbol Animal as a base type and gathers inherited and specific members together. It then verifies that the class implements every required member.
// 1. Create the symbol for the Animal interface
AnimalSymbol {
members: {
name: StringType,
makeSound: FunctionType
}
}
// 2. Process inheritance for the Dog interface
DogSymbol {
baseTypes: [AnimalSymbol],
members: {
// Inherited from Animal
name: StringType,
makeSound: FunctionType,
// Specific to Dog
breed: StringType,
wagTail: FunctionType
}
}
// 3. Verify the class implementation
// Check that all required members are implemented
After compilation, only the class implementation remains.
class Labrador {
constructor(name, breed) {
this.name = name;
this.breed = breed;
}
makeSound() {
return "Woof!";
}
wagTail() {
console.log("Wagging tail happily");
}
}
What about a type intersection? Let's look at an example that uses the intersection of User and Timestamp.
type Timestamp = {
createdAt: Date;
updatedAt: Date;
};
type User = {
id: string;
name: string;
};
type UserWithTimestamp = User & Timestamp;
const user: UserWithTimestamp = {
id: "1",
name: "Alice",
createdAt: new Date(),
updatedAt: new Date(),
};
The compiler stores the type information for each. The intersection is held merely as a list of its constituents. The properties are merged into one only at the point of actual use.
// 1. Store the type information for each type
TimestampType = ObjectType {
properties: {
createdAt: DateType,
updatedAt: DateType
}
}
UserType = ObjectType {
properties: {
id: StringType,
name: StringType
}
}
// 2. Compute the intersection type
UserWithTimestampType = IntersectionType {
types: [UserType, TimestampType],
// Expanded to the following at actual use
resolvedProperties: {
id: StringType,
name: StringType,
createdAt: DateType,
updatedAt: DateType
}
}
And after compilation, all that's left is a plain object literal.
const user = {
id: "1",
name: "Alice",
createdAt: new Date(),
updatedAt: new Date(),
};
Type Erasure
As we've seen, one of TypeScript's most important characteristics is that all type information disappears at compile time. First in line to go are type annotations.
// Before compilation
const name: string = "Alice";
// After compilation
const name = "Alice";
interface definitions vanish completely.
// Before compilation
interface User {
name: string;
}
// After compilation
// Removed completely
type definitions vanish just the same.
// Before compilation
type Status = "active" | "inactive";
// After compilation
// Removed completely
Generics disappear, type parameters and all.
// Before compilation
function identity<T>(arg: T): T {
return arg;
}
// After compilation
function identity(arg) {
return arg;
}
What remains, conversely, is actual values and implementations, class definitions (needed at runtime), and enums when used as values. In other words, whichever of interface or type you choose, there is no difference whatsoever in runtime behavior. Name tags and boxes alike get tidied away before anything runs. The difference lies purely in how it feels to write and in the compiler's bookkeeping.
Combining Them in Practice
In real code, rather than making do with only one, you end up combining the two. For API response types, for instance, you build the shared part withinterface inheritance. Only the success-or-failure part gets bundled into a type union.
// Base response type
interface BaseApiResponse {
status: number;
message: string;
}
// Response on success
interface SuccessResponse<T> extends BaseApiResponse {
data: T;
}
// Response on error
interface ErrorResponse extends BaseApiResponse {
error: string;
}
// Combine with a union type
type ApiResponse<T> = SuccessResponse<T> | ErrorResponse;
Component Props work the same way. Define the base Props with interface, build per-variant Props with intersections, and tie them together with a union at the end. Being able to express a constraint like "you can't pass color when variant is "primary"" in the type system is rather nice.
// Basic props
interface BaseButtonProps {
children: React.ReactNode;
onClick: () => void;
}
// Props per variant
type PrimaryButtonProps = BaseButtonProps & {
variant: "primary";
color?: never;
};
type SecondaryButtonProps = BaseButtonProps & {
variant: "secondary";
color: "blue" | "red" | "green";
};
// Combine with a union type
type ButtonProps = PrimaryButtonProps | SecondaryButtonProps;
The Order of Decision
If you're still torn, decide in this order. First, team conventions: consistency with the existing codebase takes priority. Next, follow the Google Style Guide, withinterface for object types and type for everything else.
If that still doesn't settle it, decide by functional requirements, namely whether you need declaration merging or inheritance. And if you're still in doubt at the very end, go with type. It has fewer restrictions and is easier to change later.
Note that when developing a library, interface is often chosen for its extensibility, since users can widen the type with declare module.
// On the library side
export interface Config {
apiUrl: string;
timeout?: number;
}
// Extendable on the user side
declare module "your-library" {
interface Config {
customOption?: boolean;
}
}
Feature Comparison
Finally, here are the differences covered so far in a table.
| Item | interface | type |
|---|---|---|
| Object type definitions | ✅ Recommended | ✅ Possible |
| Union types / primitives | ❌ Not possible | ✅ Recommended |
| Declaration merging | ✅ Possible | ❌ Not possible |
| Mapped types | ❌ Not possible | ✅ Possible |
| Inheritance | ✅ extends | ✅ Intersection types |
| Tuple types | ❌ Not possible | ✅ Possible |
| Conditional types | ❌ Not possible | ✅ Possible |
interface for object type definitions and extensible library types. Use type for primitives, unions, tuples, and complex type operations. That's the recommended approach.
That wraps up my tour of interface versus type, from syntax all the way into the compiler. Look it up by the name tag, or open the box. The difference is real, but both are erased without a trace at runtime. So what ultimately matters is not technical superiority. Follow your team's conventions and your project's requirements, and use one consistently. That's all there is to it.