type in frontend development, but when I had AI write code it used interface a lot, which confused me, so I'm sorting this out again.
I've worked on several Next.js and React projects, and everyone basically used type, so I only used it because everyone else did, with little deeper understanding.
Conclusion
My conclusion for now is as follows.
- Use
typeunless you have a reason not to- It makes "type computation" such as union types, intersection types, tuple types, and conditional types easy to handle, and it works well with React Props definitions and utility types.
- In ordinary business application development, performance problems almost never occur, so making
typethe default is natural from the standpoint of readability and flexibility.
- Use
interfacewhen using classes (OOP design) or when external extension is neededinterfacefits naturally with a class'simplements, and it is more intuitive when writing in an OOP style.- Also, because declaration merging and module augmentation are possible,
interfaceis the only choice when you want to extend a type from an external library (for example, adding a field to Express's Request, or extending styled-components' DefaultTheme).
- Following the project's policy comes first
- Large organizations such as Google sometimes explicitly decide "use
interfacefor object types." - In team development, whether you use it consistently matters far more than which one you use, and it improves the readability and maintainability of the whole codebase.
- Large organizations such as Google sometimes explicitly decide "use
Basic definitions of interface and type, and their differences
interface
interface User {
id: number;
name: string;
email: string;
}
type
type User = {
id: number;
name: string;
email: string;
};
Both can define an object type in the same way, but there are subtle differences.
Main differences
1. Declaration Merging
When you declare aninterface multiple times with the same name, they are 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;
}
(Honestly, I think this behavior is kind of crazy...)
On the other hand, declaring atype multiple times with the same name is an error.
type User = {
id: number;
name: string;
};
// Error: Duplicate identifier 'User'
type User = {
email: string;
};
2. How to write inheritance
Inheritance with interface
Aninterface can extend other interfaces and types.
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;
}
Inheritance with type (using intersection types)
Atype cannot use the extends keyword, but can do the same thing with an intersection type (&).
type Animal = {
name: string;
};
type Dog = Animal & {
breed: string;
};
3. Union types and primitive types
Atype can create aliases for union types and primitive types, but an interface cannot.
// 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
4. Conditional types and mapped types
Atype supports advanced type operations such as conditional types and mapped types.
// 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
5. Tuple types
Tuple types can be defined only withtype.
// 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;
}
6. Property overriding
When overriding properties during inheritance,interface and type behave differently.
With interface
interface Animal {
name: string | number;
}
interface Dog extends Animal {
name: string; // Can be changed to a more specific type
}
With type (intersection types)
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
};
However, with Generics, you can do the same thing with type.
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
7. Mapped Types
Atype can use mapped types, but an interface cannot.
// 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
// };
Guidelines for choosing
Industry-standard recommendations
The Google Style Guide recommends the following:
- Primitive types, union types, tuple types: use
type - Object type definitions: use
interface
These 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
// 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;
}
When to use interface
-
When defining the shape of an object
interface ApiResponse { data: any; status: number; message: string; } -
When extensibility is needed in library or API type definitions
// On the library side interface Config { apiUrl: string; } // Extended on the user side interface Config { timeout: number; } -
When implementing a class
interface Drawable { draw(): void; } class Circle implements Drawable { draw() { // Implementation } }
When to use type
-
Aliases for union types and primitive types
type Theme = "light" | "dark"; type EventHandler = (event: Event) => void; -
Complex type operations such as conditional types and mapped types
type Partial<T> = { [P in keyof T]?: T[P]; }; -
Tuple types
type Point = [number, number]; -
Composing types with intersection types
type UserWithTimestamps = User & { createdAt: Date; updatedAt: Date; };
Handling in the TypeScript compiler
Internal representation of types
The TypeScript compiler processesinterface and type in different ways.
How interface is processed
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
How type is processed
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
Type-checking performance
Advantages of interface
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
In the case of type
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
Differences in error messages
Error message for interface
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'.
Error message for type
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
Impact on compile time
Comparison in large projects
// 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;
};
Type resolution and caching
Type resolution for interface
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
Type resolution for type
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>;
};
Example of actual performance measurement
// 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
- interface: managed efficiently in the symbol table
- type: additional memory may be needed due to type expansion
Recommendations
From a performance perspective:
- Large projects: make
interfacethe default - Designs with a lot of inheritance:
interfacehas the advantage - Complex type operations:
typeis fine (when you need the functionality) - Library development: design the API with
interface
The TypeScript compiler's processing flow
The TypeScript compiler (tsc) processesinterface and type in the following steps.
1. Lexical Analysis
// Source code
interface User {
name: string;
}
type Status = "active" | "inactive";
// 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")
2. Parsing
Generating the AST (Abstract Syntax Tree):
// 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" } }
]
}
}
3. Binding
Building the symbol table:
// 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
}
4. Type Checking
Type checking for interface
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)
Type checking for type
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
5. Conversion to JavaScript (Emit)
The differences when TypeScript code is converted to JavaScript:
Before compilation (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());
}
After compilation (JavaScript)
// 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());
}
Details of compile-time processing
Handling declaration merging
Before compilation
interface Window {
customProperty: string;
}
interface Window {
anotherProperty: number;
}
// Usage example
window.customProperty = "test";
window.anotherProperty = 42;
Processing inside the compiler
// 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
// Type information is removed, leaving only the implementation
window.customProperty = "test";
window.anotherProperty = 42;
Handling inheritance
Interface inheritance (before compilation)
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");
}
}
Inheritance resolution inside the compiler
// 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
class Labrador {
constructor(name, breed) {
this.name = name;
this.breed = breed;
}
makeSound() {
return "Woof!";
}
wagTail() {
console.log("Wagging tail happily");
}
}
Intersection type handling for type
Before compilation
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(),
};
Intersection type resolution inside the compiler
// 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
}
}
After compilation
const user = {
id: "1",
name: "Alice",
createdAt: new Date(),
updatedAt: new Date(),
};
Details of Type Erasure
One of TypeScript's most important characteristics is that all type information is removed at compile time.
Elements that are removed
-
Type annotations
// Before compilation const name: string = "Alice"; // After compilation const name = "Alice"; -
interface definitions
// Before compilation interface User { name: string; } // After compilation // Removed completely -
type definitions
// Before compilation type Status = "active" | "inactive"; // After compilation // Removed completely -
Generics
// Before compilation function identity<T>(arg: T): T { return arg; } // After compilation function identity(arg) { return arg; }
Elements that remain
- Actual values and implementations
- Class definitions (needed at runtime)
- enum (when used as a value)
Practical examples
Type definitions for API responses
// 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;
Props definitions for components
// 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;
Practical decision criteria
When you can't decide, I recommend deciding in the following order of priority.
- Team conventions: prioritize consistency with the existing codebase
- Google Style Guide:
interfacefor object types,typefor everything else - Functional requirements: decide based on the need for declaration merging or inheritance
- When in doubt,
type: it has fewer restrictions and is easier to change later
Considerations in library development
When developing a library,interface is often chosen with an emphasis on extensibility.
// On the library side
export interface Config {
apiUrl: string;
timeout?: number;
}
// Extendable on the user side
declare module "your-library" {
interface Config {
customOption?: boolean;
}
}
Summary
| 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 |
Recommended approach:
- Object type definitions:
interface - Primitives, unions, tuples:
type - Complex type operations:
type - Extensible types in libraries:
interface
Ultimately, the most important thing is to stay consistent with your team's coding conventions and the project's requirements.