DEV Community

Cover image for type vs interface in 2026: Stop the Religious War
Gabriel Anhaia
Gabriel Anhaia

Posted on

type vs interface in 2026: Stop the Religious War


You open a pull request that adds a small DTO. You used type.
A reviewer leaves a comment: "we use interface for object
shapes." You change it. The next PR you open uses interface.
A different reviewer leaves a comment: "we use type for
everything for consistency." Both reviewers are senior. Both
have been on the team for years. Neither has ever stopped to
write down the rule, because in their head the rule is obvious.
The rules are different.

This is where every TypeScript codebase loses an hour a week.
The type versus interface argument has been running in
GitHub issues and Reddit threads since TS 1.6. It is now 2026.
The compiler has had nine years to make the two converge, and
it largely has. For about ninety-five percent of the shapes
you write in a working codebase, both keywords compile to the
same thing, generate the same .d.ts, surface the same
hover-over text, and produce errors of comparable quality.

The other five percent is what this post is about. There are
four cases where exactly one of the two keywords works and the
other will not compile, hover correctly, or merge the way you
need. Once you can name those four cases, the rest of the
debate is bikeshedding. Ship the rule in your linter and let
review cycles cover the bug in the next file over.

What the TypeScript handbook actually says

The official handbook has a short section called
Differences Between Type Aliases and
Interfaces
.
It is a four-row comparison table with a couple of notes on
performance and error messages underneath. The summary is
honest. Most features work in both. The differences are small.
Pick one and be consistent. The handbook itself stops short of
giving a firm rule because the team that maintains the language
wants both to keep working.

That neutrality is what fuels the religious war. Without an
official "use this," teams fall back to taste, prior language,
and whichever version of the docs they read first. So here is
the practical version of the rule: use whichever one your file
already uses, and reach for the other only when the case below
forces it.

Case 1: declaration merging — interface only

This is the one case where the two keywords are not
interchangeable at all, and the one that matters most for any
package author or any team that pulls in third-party types.

Two interface declarations with the same name in the same
scope merge. Their members are combined into a single
interface as if you had written one block. Two type
declarations with the same name in the same scope are a
duplicate-identifier error.

interface User {
  id: string;
}

interface User {
  email: string;
}

const u: User = { id: "u_1", email: "a@b.c" };
// ok — User has both fields
Enter fullscreen mode Exit fullscreen mode
type User = { id: string };
type User = { email: string };
// error: Duplicate identifier 'User'.
Enter fullscreen mode Exit fullscreen mode

Declaration merging is how the TypeScript ecosystem augments
types it does not own. Three patterns rely on this: adding a
property to Express.Request for a custom auth middleware,
adding a typed key to globalThis for a feature flag, adding
fields to process.env from a typed config loader. All of
them merge into an interface in a third-party module, and
type cannot do them.

// global.d.ts
declare global {
  namespace Express {
    interface Request {
      user?: { id: string; roles: string[] };
    }
  }
}

export {};
Enter fullscreen mode Exit fullscreen mode

This works because Express.Request is itself an interface
in @types/express. Your file declares another interface
Request
in the same namespace; the compiler merges them; every
handler in your app now sees req.user typed correctly. If
the upstream definition were a type, you would not have a
clean way in. The whole pattern of module augmentation rests
on interface.

If you write a library that exposes shapes consumers might
need to extend (config objects, plugin contracts, request and
response payloads), declare them as interface. You are giving
your users a hook the language recognises.

Case 2: long-shape error messages — interface wins

Run a real codebase against TypeScript 5.5 and watch the
errors when a deeply-nested object does not satisfy a target
shape. With an interface, the error message tends to print
the interface's name and the missing property:

Property 'startedAt' is missing in type
  '{ id: string; status: "pending"; }'
but required in type 'Job'.
Enter fullscreen mode Exit fullscreen mode

With a type alias for the same shape, the compiler prints
the structural expansion of both sides. For nested objects with
a dozen optional properties, that expansion can run to thirty
lines and the actual missing field gets buried.

Property 'startedAt' is missing in type
  '{ id: string; status: "pending"; }'
but required in type
  '{ id: string; startedAt: Date;
     status: "pending" | "running" | ... ; ... }'.
Enter fullscreen mode Exit fullscreen mode

This behaviour has been refined across releases (5.0 was a big
step), and the gap has narrowed for simple shapes. For the
deep configuration types and request payloads that show up in
real apps, interface still produces the more readable
message more often. If you build a library and your error
messages are part of the developer experience, that pushes the
needle toward interface for any shape consumers will see in
errors.

A second small thing: tooling that walks .d.ts files (API
extractors, doc generators, IDE outline views) treats named
interfaces as first-class entities. A type alias for an
object is more often inlined or expanded.

Case 3: unions, intersections, primitives, tuples — type only

interface describes object shapes. That is its whole job.
The day you need to say "this is a string or this object" or
"this is exactly the tuple [number, number]," interface stops
working and type is the only tool that compiles.

type Status = "pending" | "active" | "archived";

type Result<T> =
  | { ok: true; value: T }
  | { ok: false; error: Error };

type Point = readonly [number, number];

type Json =
  | string
  | number
  | boolean
  | null
  | Json[]
  | { [k: string]: Json };
Enter fullscreen mode Exit fullscreen mode

None of those are expressible as an interface. Unions,
discriminated unions, primitives, tuples, the recursive Json
type are type-system constructs. They live where the language
puts them, and type is the keyword that matches the feature.

The same goes for the small but useful aliases that shorten
real signatures.

// using the global fetch Request/Response types
type Handler<T> = (req: Request, body: T) => Promise<Response>;
type Predicate<T> = (x: T) => boolean;
type Unwrap<T> = T extends Promise<infer U> ? U : T;
// note: TS ships a built-in `Awaited<T>` since 4.5 that
// also unwraps nested Promises; use it in real code.
Enter fullscreen mode Exit fullscreen mode

A function alias is a type. An interface with a call
signature is legal, and lib.dom.d.ts itself ships several
(EventListener, IntersectionObserverCallback), but type
aliases are the idiomatic spelling for function shapes outside
ambient declaration files.

Case 4: mapped, conditional, and template-literal types — type only

The expressive end of TypeScript's type system is built on
mapped types, conditional types, infer, and template literal
types. That is the half that makes libraries like ts-pattern,
Zod, and Effect possible. None of those are interface features.

type Keys<T> = keyof T;

// MyPartial illustrates the mapped-type pattern; TS already
// ships the built-in `Partial<T>` that does this.
type MyPartial<T> = { [K in keyof T]?: T[K] };

type ReadonlyDeep<T> = T extends object
  ? { readonly [K in keyof T]: ReadonlyDeep<T[K]> }
  : T;

type Capitalize<S extends string> =
  S extends `${infer F}${infer R}`
    ? `${Uppercase<F>}${R}`
    : S;

type EventName<T extends string> = `on${Capitalize<T>}`;
// EventName<"click"> = "onClick"
Enter fullscreen mode Exit fullscreen mode

If your file declares any of those, it is a type file. The
compiler rejects an interface body that tries to be a union, a
conditional, or a template literal. That is a language-feature
constraint, not a stylistic call.

The practical consequence: utility-type modules, Zod or Valibot
schema-derived types, and branded-type helpers will be
type-heavy. Your domain modules that describe DTOs and
entities can be either.

A one-rule policy that ends the war

After the four cases above, here is the rule a sane team
ships:

Use interface for object shapes that other code might need
to extend or that appear in public API surfaces. Use type
for everything else.

That one sentence covers all four cases. Declaration merging
needs interface. Library-API shapes that benefit from named
errors need interface. Unions, tuples, primitives,
conditional types, and mapped types need type. The remaining
ninety-five percent (your internal DTOs, your private helper
shapes, the Props of a component) works either way, and the
rule lets the file's primary shape decide.

If you want this in a linter, the
@typescript-eslint/consistent-type-definitions
rule lets you pick one default and the autofixer rewrites the
file. If your codebase is mostly DTOs and entities, set
interface as the default and treat the four type-only cases
above as the explicit exceptions. If your codebase leans the
other way, toward utility types and schemas, set type as the
default and treat declaration-merge files as the exception.

Which default you pick is small. Having any default is what
saves the hour a week.

What about performance

You will see the claim that interface is faster to type-check
than type because the compiler caches interface relations.
This was measurable in TS 3.x and early 4.x on large codebases
with deeply-nested generic objects. The compiler has had a lot
of work poured into the relation cache since. On TS 5.x, the
gap on object shapes is small enough that you will not see it
on a project unless you are at Microsoft- or Slack-scale
codebase size.

The real performance traps in TypeScript are deep
conditional-type chains, large discriminated unions used as
function parameters, and recursive types instantiated at depth.
All three are type-only constructs. If you have performance
pain, profile the type-check (tsc --extendedDiagnostics,
tsc --generateTrace trace, then load the resulting directory
into chrome://tracing or ui.perfetto.dev);
do not switch every interface to a type or vice versa hoping it
helps.

Stop arguing, write the rule down

The most expensive thing about type versus interface is the
review cycles. A team without a written default spends review
attention on the same comment every week — attention that
should have caught the bug in the next file over.

Pick one default. Write the four type-only cases and the
declaration-merge case in a five-line block at the top of your
contributing guide. Turn on
@typescript-eslint/consistent-type-definitions with the
chosen default. The bikeshed disappears, the codebase ends up
consistent inside a few PRs of autofix, and the next person to
join the team gets the rule on day one instead of inferring it
from comment archaeology.

Both keywords stay in the language. They overlap on most shapes
and diverge on four narrow cases. Agree on the divergence and
the rest of the argument goes away.


If this was useful

type versus interface is one of the small daily decisions
TypeScript Essentials takes a position on, alongside the rest
of the day-to-day surface area: narrowing, modules, async,
tooling, the type-system features that show up in every working
codebase. If the four cases above gave you the rule you needed
and you want the same treatment for the other thirty decisions
that come up every week in a TypeScript file, that is the book.
The TypeScript Type System picks up where this post ends, with
the conditional, mapped, and template-literal types that make
the type-only column above expressive in the first place.

If you are coming from JVM languages where the nominal-versus-
structural distinction lands differently, Kotlin and Java to
TypeScript
covers that bridge. If you are coming from PHP 8+,
PHP to TypeScript covers the same ground from the other side.
TypeScript in Production covers the build, monorepo, and
dual-publish concerns the type-system choice does not touch.

The five-book set:

  • TypeScript Essentials — From Working Developer to Confident TS, Across Node, Bun, Deno, and the Browser — entry point: amazon.com/dp/B0GZB7QRW3
  • The TypeScript Type System — From Generics to DSL-Level Types — deep dive: amazon.com/dp/B0GZB86QYW
  • Kotlin and Java to TypeScript — A Bridge for JVM Developers — bridge for JVM devs: amazon.com/dp/B0GZB2333H
  • PHP to TypeScript — A Bridge for Modern PHP 8+ Developers — bridge for PHP devs: amazon.com/dp/B0GZBD5HMF
  • TypeScript in Production — Tooling, Build, and Library Authoring Across Runtimes — production layer: amazon.com/dp/B0GZB7F471

All five books ship in ebook, paperback, and hardcover.

The TypeScript Library — the 5-book collection

Top comments (0)