I have always used 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 type unless 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 type the default is natural from the standpoint of readability and flexibility.
  • Use interface when using classes (OOP design) or when external extension is needed
    • interface fits naturally with a class's implements, and it is more intuitive when writing in an OOP style.
    • Also, because declaration merging and module augmentation are possible, interface is 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 interface for 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.

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 an interface 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 a type 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

An interface 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)

A type 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

A type 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

A type 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 with type.
// 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

A type 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

https://ncjamieson.com/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

  1. When defining the shape of an object

    interface ApiResponse {
      data: any;
      status: number;
      message: string;
    }
    
  2. 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;
    }
    
  3. When implementing a class

    interface Drawable {
      draw(): void;
    }
    
    class Circle implements Drawable {
      draw() {
        // Implementation
      }
    }
    

When to use type

  1. Aliases for union types and primitive types

    type Theme = "light" | "dark";
    type EventHandler = (event: Event) => void;
    
  2. Complex type operations such as conditional types and mapped types

    type Partial<T> = {
      [P in keyof T]?: T[P];
    };
    
  3. Tuple types

    type Point = [number, number];
    
  4. Composing types with intersection types

    type UserWithTimestamps = User & {
      createdAt: Date;
      updatedAt: Date;
    };
    

Handling in the TypeScript compiler

Internal representation of types

The TypeScript compiler processes interface 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:

  1. Large projects: make interface the default
  2. Designs with a lot of inheritance: interface has the advantage
  3. Complex type operations: type is fine (when you need the functionality)
  4. Library development: design the API with interface

The TypeScript compiler's processing flow

The TypeScript compiler (tsc) processes interface 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

  1. Type annotations

    // Before compilation
    const name: string = "Alice";
    
    // After compilation
    const name = "Alice";
    
  2. interface definitions

    // Before compilation
    interface User {
      name: string;
    }
    
    // After compilation
    // Removed completely
    
  3. type definitions

    // Before compilation
    type Status = "active" | "inactive";
    
    // After compilation
    // Removed completely
    
  4. Generics

    // Before compilation
    function identity<T>(arg: T): T {
      return arg;
    }
    
    // After compilation
    function identity(arg) {
      return arg;
    }
    

Elements that remain

  1. Actual values and implementations
  2. Class definitions (needed at runtime)
  3. 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.

  1. Team conventions: prioritize consistency with the existing codebase
  2. Google Style Guide: interface for object types, type for everything else
  3. Functional requirements: decide based on the need for declaration merging or inheritance
  4. 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

Iteminterfacetype
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.