Developer Guide

Interfaces vs Types in TypeScript: 2026 Best Practices

Clarify when to use interfaces vs types in TypeScript for scalable production apps — with concrete examples and real-world tradeoffs.

Mahdi Ben RhoumaMahdi Ben Rhouma
Updated
8 min read

TL;DR#

If you're deciding between interface and type in TypeScript, the core rule is: interfaces for object shapes and public contracts, types for everything else. The confusion arises because both can define object structures, but they diverge in behaviour around declaration merging, unions and mapped types.

If that doesn't resolve your doubt, scroll to the practical comparison — there are four concrete scenarios where one clearly wins.

What you'll see#

You’ll see this question pop up constantly in code reviews, Stack Overflow threads, and internal RFCs:

"Should I define my props as an interface or a type alias in React? What about API response types? Can I extend a type? Why does my linter complain about mixing both?"

The confusion isn’t theoretical — it directly impacts how maintainable your type definitions are, how easily teams collaborate, and whether your library’s public API feels intuitive or confusing.

It happens when you’re building a production app — especially with Next.js, Supabase, or Firebase — where type consistency across frontend and backend is critical. The behavior is the same across TypeScript v4.9+, React 18+, and modern Next.js versions.

Root cause#

TypeScript’s interface and type alias both describe shapes and constraints, but they were designed for different purposes and evolved under different constraints.

The interface keyword predates TypeScript — it mirrors JavaScript’s informal concept of interfaces in object-oriented design. It supports declaration merging, meaning you can define the same interface in multiple places, and TypeScript will merge them into one. This is how code adds members to built-in global types such as Window — the TypeScript handbook's own example.

The type alias was introduced later (v1.4) to support union types, intersections, and primitive aliases — things interfaces simply couldn’t express. But because type X = { a: string } looks like interface X { a: string }, developers assumed they were interchangeable.

They’re not.

The relevant code path is:

ts
// src/types/api.ts
// A common pattern that causes confusion
export interface User {
  id: string;
  name: string;
}
 
// vs.
 
export type User = {
  id: string;
  name: string;
};

Both compile on their own. Now declare User a second time in the same file:

ts
// src/types/api.ts
export interface User {
  id: string;
  name: string;
}
 
export interface User {
  email?: string;
}
// Merges with the declaration above — no error.
// Now `User` has `id`, `name`, and `email`.
 
// But if a type alias reuses the name:
export type User = {
  email?: string;
};
// ❌ error TS2300: Duplicate identifier 'User'.
// (tsc reports it on every `User` declaration in the file.)

A type alias cannot be re-opened — redeclaring it is a compile-time error. Interfaces are open-ended by design. Merging only happens within one scope, though: an export interface User in a different module file is a separate, unrelated type. To add fields to another module's interface, use module augmentation (Example 4).

Interfaces vs Types at a Glance#

The TypeScript handbook says that for the most part the choice is personal preference, and offers one heuristic: use interface until you need features from type. The table below applies that heuristic:

ScenarioUse interfaceUse type
Public API contracts (e.g., props, API responses)✅ Yes — supports merging, clearer intentWorks, but cannot be re-opened by declaration merging (an interface can still extends it)
Utility types (e.g., Partial<T>, Pick<T, K>)❌ No — can’t express unions✅ Yes — required
Union types (e.g., `stringnull`)❌ No — interfaces can’t be unions
Combining shapes (e.g., Base & Extra)✅ interface C extends Base, Extra {} — preferred for compiler performance✅ type C = Base & Extra
Extensibility across files (e.g., library extensions)✅ Yes — declaration merging❌ No — fails on redeclaration

Let’s walk through real examples.

Example 1: Public Props in React Components#

In a Next.js app, you’ll often define props for components. A typical pattern:

tsx
// src/components/UserCard.tsx
import { User } from '@/types/api';
 
interface UserCardProps {
  user: User;
  onEdit?: () => void;
  onDelete?: () => void;
}
 
export function UserCard({ user, onEdit, onDelete }: UserCardProps) {
  return (
    <div className="card">
      <h3>{user.name}</h3>
      {user.email && <p>{user.email}</p>}
      <div className="actions">
        {onEdit && <button onClick={onEdit}>Edit</button>}
        {onDelete && <button onClick={onDelete}>Delete</button>}
      </div>
    </div>
  );
}

Why interface here? Because:

  • It’s a public contract — other developers will extend or override this component.
  • Once exported, other files can extend it (interface AdminCardProps extends UserCardProps { … }) or augment it with declare module.
  • It aligns with how React’s own types are defined (React.FC uses interfaces internally).

With type UserCardProps = ..., the props could still be extended (interface X extends UserCardProps compiles against an object type alias) but never re-opened by declaration merging. You can also extend a type via intersection (type Props = Base & Extra), but the TypeScript performance wiki prefers interface … extends: an interface forms a single flat object type that detects property conflicts, while an intersection merges properties recursively and can produce never.

Example 2: API Response Types#

Supabase returns JSON like:

json
{
  "id": "uuid",
  "name": "Alice",
  "email": "alice@example.com",
  "created_at": "2025-12-01T10:00:00Z"
}

A matching definition:

ts
// src/types/supabase.ts
export interface SupabaseUser {
  id: string;
  name: string;
  email: string;
  created_at: string;
}
 
// Later, in auth hooks:
export type AuthResponse = {
  data: SupabaseUser | null;
  error: Error | null;
};

Notice how SupabaseUser is an interface, but AuthResponse is a type. Why?

  • SupabaseUser is a shape — it’s likely to be extended (e.g., by Supabase’s real-time updates or soft-delete flags).
  • AuthResponse is a result wrapper whose properties have union types (data: SupabaseUser | null and error: Error | null). An interface can hold exactly the same properties — interface AuthResponse { data: SupabaseUser | null; error: Error | null } compiles — so this is a convention for a result object that is never re-opened, not a technical limit.

You’ll see this pattern in production: interfaces for nouns (entities), types for verbs (operations, results).

Example 3: Utility Types and Generics#

Here’s where type shines — and interfaces fail:

ts
// src/utils/types.ts
export type Optional<T> = {
  [K in keyof T]?: T[K];
};
 
export type Nullable<T> = T | null;
 
export type UserWithOptionalEmail = Optional<Pick<User, 'email'>>;
 
// An interface CAN extend the result of a mapped type,
// because it resolves to an object type with statically known members:
export interface UserWithOptionalEmailShape extends Optional<Pick<User, 'email'>> {}
 
// ❌ What an interface cannot extend is a union:
// interface Bad extends Nullable<User> {}
// error TS2312: An interface can only extend an object type or
// intersection of object types with statically known members.

Declaring a mapped type ([K in keyof T], with optional as remapping) needs type: an interface body has no such syntax. Extending the resulting object type from an interface is allowed.

Example 4: Extending Across Files#

In a large app, you might want to add fields to a type from another file — for testing or feature flags.

ts
// src/types/test-augment.ts
import './api';
 
declare module './api' {
  interface User {
    isTestUser?: boolean;
  }
}
 
// Importing `User` and redeclaring it locally fails instead:
// import { User } from './api';
// interface User { isTestUser?: boolean }
// ❌ error TS2440: Import declaration conflicts with local declaration of 'User'.

This is module augmentation, a form of declaration merging. Interfaces take part in it; a type alias never does.

The fix#

Adopt this policy:

  1. Use interface for object shapes — especially public APIs, props, and domain models.
  2. Use type for everything else — unions, intersections, primitives, mapped types, and utility aliases.

Here’s how to refactor a common anti-pattern:

ts
// ❌ Before: mixing types and interfaces inconsistently
export type User = {
  id: string;
  name: string;
};
 
export interface Session {
  user: User;
  token: string;
}
 
export type AuthResult = {
  success: boolean;
  user?: User;
  error?: string;
};
ts
// ✅ After: consistent, intentional separation
// src/types/api.ts
export interface User {
  id: string;
  name: string;
}
 
// src/types/auth.ts
export interface Session {
  user: User;
  token: string;
}
 
export type AuthResult = {
  success: boolean;
  user?: User;
  error?: string;
};

That single change addresses the cause because it enforces semantic clarity — when you see interface, you know it’s a shape that may grow; when you see type, you know it’s a computed or composite definition.

Step by step#

  1. Search your codebase for export type that defines object literals (e.g., export type User = { ... }).
  2. Replace those with export interface if:
    • The type represents a domain entity (e.g., User, Post, Order).
    • You expect it to be extended or augmented.
    • It’s part of a public API (e.g., exported from a library or shared package).
  3. Keep type for:
    • Unions (string | null)
    • Intersections (Base & Extra)
    • Primitives (type ID = string)
    • Mapped types (type DeepReadonly<T> = ...)
  4. Run tsc --noEmit to verify no errors.
  5. Update ESLint rules to enforce this (see Prevention).

Verify the fix#

Run:

bash
npx tsc --noEmit --pretty

On success tsc --noEmit prints nothing and exits with code 0; any type error is printed with its TSxxxx code.

If you previously had duplicate type declarations, they’ll now compile — because interfaces merge.

To test merging explicitly:

ts
// src/types/user-merge.ts
export interface User {
  id: string;
}
 
export interface User {
  name: string;
}
 
// Now `User` has both `id` and `name`.
const user: User = { id: '1', name: 'Alice' }; // ✅ Valid

If you try the same with type, it fails:

ts
// ❌ This throws: "Duplicate identifier 'User'."
export type User = { id: string };
export type User = { name: string };

Run this in ts-node or tsc --noEmit, and you’ll see the error immediately.

Variant A — Library authors#

If you’re publishing an npm package, always use interface for public types. Why?

  • Consumers can extend either form: interface MyUser extends YourUser { extra: string } compiles whether YourUser is an interface or an object type alias.
  • Only an interface can be augmented with declare module, which lets consumers add fields to your type itself.
  • Tools like dts-bundle-generator and tsup handle interfaces better for type extraction.

Variant B — Legacy codebases#

If you inherit a codebase with inconsistent usage, don’t refactor blindly. Instead:

  1. Run npx knip or npx ts-unused-exports tsconfig.json to find unused or ambiguous types, then confirm with npx tsc --noEmit.
  2. Group types by usage: domain models → interface, utilities → type.
  3. Use eslint with @typescript-eslint rules to enforce the split going forward.

Why this happens (and how to avoid it next time)#

TypeScript’s flexibility is a double-edged sword. The overlap between interface and type in basic object definitions creates ambiguity — especially for developers coming from JavaScript or other languages where "type" and "interface" are distinct concepts.

The underlying invariant is: interfaces model shapes, types model expressions. Once you internalize that, the choice becomes obvious.

To prevent regressions:

  • Add this ESLint rule:
json
// .eslintrc.json
{
  "rules": {
    "@typescript-eslint/consistent-type-definitions": [
      "error",
      "interface"
    ]
  }
}

With the "interface" option, the rule flags object type literals declared with type and leaves unions and primitive aliases alone, which matches the policy above.

  • Write a CONTRIBUTING.md section that states: "Use interface for domain models, type for utilities."

  • In CI, run:

bash
npx tsc --noEmit --project tsconfig.json && \
npx eslint src --ext .ts,.tsx

For the wider move from JavaScript — enabling allowJs, converting file by file and tightening strict mode — see TypeScript Migration Guide 2026: Upgrade JavaScript Projects Safely.

Further reading: How to Convert a String to Number in TypeScript: 2026 Fix, TypeScript ! Operator: Fix Object Is Possibly Undefined.

Frequently Asked Questions

|

Have more questions? Contact us

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

One email a month — the latest explained errors

What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.