DEV Community

Cover image for Interfaces vs Types in TypeScript: 2026 Best Practices
Mahdi BEN RHOUMA
Mahdi BEN RHOUMA

Posted on Originally published at iloveblogs.blog

Interfaces vs Types in TypeScript: 2026 Best Practices

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 behavior around merging, generics, and tooling.

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

  • Symptom: Uncertainty about which to use — interfaces or types — leading to inconsistent codebases and fragile type definitions.
  • Root cause: Overlap in basic usage (both define object shapes) masks deeper differences in extensibility, merging, and tooling support.
  • Fix: Adopt a consistent policy: use interface for object shapes and public APIs, type for unions, intersections, primitives, and utility types.
  • Verification: Run tsc --noEmit and check that your types compile without errors, then verify that your IDE shows correct autocompletion and error highlighting.

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 libraries like React extend global types (e.g., Window or Document).

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:

// 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;
};
Enter fullscreen mode Exit fullscreen mode

Both compile. But consider this:

// src/types/extended.ts
export interface User {
  email?: string;
}

// This merges with the first `User` interface — no error.
// Now `User` has `id`, `name`, and `email`.

// But if you try this with `type`:
export type User = {
  email?: string;
};
// ❌ Error: Duplicate identifier 'User'.
Enter fullscreen mode Exit fullscreen mode

The type alias is immutable — redefining it is a compile-time error. Interfaces are open-ended by design.

Interfaces vs Types at a Glance

Here’s the decision tree I use in production across 12+ TypeScript projects:

Scenario Use interface Use type
Public API contracts (e.g., props, API responses) ✅ Yes — supports merging, clearer intent ❌ No — can’t extend later
Utility types (e.g., Partial<T>, Pick<T>) ❌ No — can’t express unions ✅ Yes — required
Union types (e.g., `string null`) ❌ No — interfaces can’t be unions
Intersection types (e.g., Base & Extra) ❌ No — interfaces can’t intersect ✅ Yes — or convert to interface first
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. Here’s what I do:

// 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Why interface here? Because:

  • It’s a public contract — other developers will extend or override this component.
  • We may want to augment it in tests or storybook (e.g., interface UserCardProps { loading?: boolean }).
  • It aligns with how React’s own types are defined (React.FC uses interfaces internally).

If we’d used type UserCardProps = ..., we’d lose that extensibility. You can extend a type via intersection (type Props = Base & Extra), but it’s less intuitive and breaks autocompletion in some editors.

Example 2: API Response Types

Supabase returns JSON like:

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

I define the type like this:

// 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;
};
Enter fullscreen mode Exit fullscreen mode

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 an object type whose properties have union types (data: SupabaseUser | null and error: Error | null). Expressing those nullable properties inline is cleaner with type than with an interface.

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:

// 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'>>;

// ❌ This won’t work with interface:
// interface UserWithOptionalEmail extends Optional<Pick<User, 'email'>> { }
// You can’t extend a mapped type like `Optional`.
Enter fullscreen mode Exit fullscreen mode

Mapped types (keyof, in, as) only work with type. Interfaces don’t support syntax like [K in keyof T].

Example 4: Extending Across Files

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

// src/types/global.d.ts
import { User } from './api';

// Extend the interface — safe and merged:
interface User {
  isTestUser?: boolean;
}

// But this fails:
// type User = { isTestUser?: boolean }; // ❌ Duplicate
Enter fullscreen mode Exit fullscreen mode

This is how libraries like @testing-library/react extend React types — they rely on declaration merging, which only works with interface.

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:

// ❌ 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;
};
Enter fullscreen mode Exit fullscreen mode
// ✅ 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;
};
Enter fullscreen mode Exit fullscreen mode

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:

npx tsc --noEmit --pretty
Enter fullscreen mode Exit fullscreen mode

You should see clean output like:

Found 0 errors.
Enter fullscreen mode Exit fullscreen mode

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

To test merging explicitly:

// 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
Enter fullscreen mode Exit fullscreen mode

If you try the same with type, it fails:

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

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 may want to extend your types (e.g., interface MyUser extends YourUser { extra: string }).
  • type aliases are not extensible — you’d force them into intersections, which are harder to read and debug.
  • 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:
// .eslintrc.json
{
  "rules": {
    "@typescript-eslint/consistent-type-definitions": [
      "error",
      "interface"
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

This enforces interface for object definitions, but not for unions or mapped types — so pair it with a custom rule or code review.

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

  • In CI, run:

npx tsc --noEmit --project tsconfig.json && \
npx eslint src --ext .ts,.tsx
Enter fullscreen mode Exit fullscreen mode

I cover this in detail in TypeScript Migration Guide 2026: Upgrade JavaScript Projects Safely, where we walk through refactoring a legacy JS codebase to TypeScript with strict type policies.

Related


Originally published at https://www.iloveblogs.blog

Top comments (0)