iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
📐

Why You Should Prefer interface over type in TypeScript

に公開3

Introduction

When defining component Props or object types in TypeScript, you've probably wondered at least once whether to use type or interface.

You often see opinions like "either is fine" or "as long as the team is consistent, it's okay."
However, I argue that you should basically use interface for clear reasons.

In this article, I will explain why interface is superior and in what scenarios type should be used, drawing from a real-life experience where I encountered serious performance issues with React Props.

The appeal of type aliases and the traps lurking behind them

First, why do many developers tend to choose type?
It's because of the good developer experience (DX).

Types defined with type show the finally resolved concrete type information inline when you hover over them in editors like VSCode.

As an example, let's define some React Props.

Interface type definition

export interface IconButtonProps extends HTMLAttributes<HTMLButtonElement> {
  icon: React.ReactNode;
}

export const IconButton = (props: IconButtonProps) => {
  // ...
};

interface ButtonWithIconProps

In the case of interface, only the name was displayed.

Type alias definition

export type IconButtonProps = HTMLAttributes<HTMLButtonElement> & {
  icon: React.ReactNode;
};

export const IconButton = (props: IconButtonProps) => {
  // ...
};

type ButtonWithIconProps = ButtonBaseProps & IconProps & {
icon: React.ReactNode;
}

The declared content was displayed.

In this way, with type, even for composed types, the final structure is visible at a glance.
I also used type constantly in my professional work because of this "clarity."
However, this would eventually lead to a major problem.

Performance Hell Caused by Type Aliases

It happened one day as the product my team was developing grew and the codebase became larger. Without any warning, editor type checking became abnormally slow. Every time I typed a character, the CPU would roar, and TypeScript type information refused to appear.

Even more serious was the build time in CI/CD. The type checking by tsc, which used to complete in about a minute, suddenly started taking over 30 minutes.

Fortunately, this phenomenon was only occurring in a specific branch before merging, so we were able to temporarily stop work on that branch and continue development on others.

However, the changes in this branch were important, and we wanted to merge them as soon as possible.

The entire team spent over a week trying everything—reviewing settings, downgrading libraries—but the cause remained unknown.

The commit where the problem occurred was adding a form field using a common form component.

This common form component has a relatively complex structure, including a mechanism to infer types from a validation library's schema.

However, since libraries like TanStack, which have even larger and more complex type systems, weren't experiencing similar problems, I started to think that it wasn't just a simple matter of "it's slow because the types are complex."

At my wit's end, I tried a hypothesis as a last resort: "Could type be the cause?"

As a test, I wrote a regular expression to mechanically batch-replace type definitions from type to interface across the entire project and ran it.

And what do you know? The editor lag that had been tormenting us disappeared, and the build time returned to one minute. The culprit was the type keyword that we had believed was so convenient.

If you are facing similar issues

I have prepared the regular expressions you can use for batch replacement in VSCode!

Open the search panel with Ctrl + Shift + F or Cmd + Shift + F, enable regular expressions, and use the following settings for search and replace.

Find: ^(\s*)(?:export\s+|declare\s+)*type\s+([A-Za-z_$][\w$]*(?:<[^>]*>)?)\s*=\s*(.+?)\s*&\s*\{\s*\r?\n([\s\S]*?)^\1\}\s*;?

Replace: $1interface $2 extends $3 {
$4$1}

If there are multiple intersections, apply this repeatedly:

Find: ^(\s*interface\b[^{]*\bextends\b[^{}]*?)\s*&\s*

Replace: $1, 

Why is interface superior in performance?

The reason is simple: type and interface have different timing for type calculation.

type: Eager Evaluation

type is a type alias. In other words, it is a feature to give another name to an existing type. When the TypeScript compiler encounters a type, it resolves and expands the type recursively on the spot, turning it into a single concrete type before proceeding.


The fact that we could see the concrete types when hovering in the previous example is proof that this "eager evaluation" is taking place.

In complex type definitions where intersection types (&) are layered multiple times, this calculation cost increases exponentially, significantly degrading the overall performance of type checking.

interface: Lazy Evaluation

On the other hand, an interface declares a new named object type. The compiler treats the interface as a single name (symbol).

A type defined with an interface defers the complete calculation of its internal structure until the type information is actually needed, such as when its properties are referenced. This is lazy evaluation.

This is why only the name is displayed when you hover over an interface.

Through this lazy evaluation mechanism, interface can minimize the impact on performance, no matter how complex the inheritance or how large the project becomes. If you look at the code of large-scale OSS libraries, the reason most of them use interface for type definitions is this scalability.

The fact that interface is often more performant is also explicitly stated in the official TypeScript documentation.

  • Using interfaces with extends can often be more performant for the compiler than type aliases with intersections

https://www.typescriptlang.org/docs/handbook/2/everyday-types.html?utm_source=chatgpt.com#type-aliases

When should type be used?

So, should we avoid using type altogether? No, that's not the case.
You should use type only when defining types that cannot be expressed with an interface.

Specifically, these are the following cases:

  1. When defining Union types

    type Status = 'success' | 'error' | 'loading';
    
  2. When defining Tuple types

    type UserTuple = [name: string, age: number];
    
  3. When performing complex type operations such as Mapped Types

    type ReadonlyUser = {
      readonly [K in keyof User]: User[K];
    };
    
  4. When aliasing primitive types

    type UserID = string;
    

In cases like these, other than defining the "shape" of an object, type demonstrates its true value.

Bonus: Improving Readability in Interfaces - Learning from OSS

In large-scale OSS libraries, several techniques are used to improve readability while still using interface.

Leveraging JSDoc Comments

Detailed description displayed when focusing on interface PageProps<AppRoute extends AppRoutes>

For example, in Next.js type definitions, JSDoc comments are added to interface so that concrete descriptions are displayed on hover.

Furthermore, interface names are chosen to be specific and descriptive, ensuring their roles are understood just by reading the code.

Leveraging Utility Types

alt text

In Hono's type definitions, a utility type called Simplify<T> is used just before exposing types to users to make them easier to read on hover.

This is a type utility implemented as follows to expand types included in inheritance:

type Simplify<T> = { [K in keyof T]: T[K] } & {};

This is effective when used once at the leaf level and when the number of properties is small.

By making these adjustments, you can improve the developer experience while still leveraging the benefits of interface.

Conclusion: Use interface as a rule, and type only for what it can't do

I sometimes see arguments that "it's better to stick to one or the other," but I believe this is a mistake. type and interface have different roles to begin with and should not be unified.

The rule we should follow is simple:

When defining the shape of an object, use interface first. Use type only when you need to define a type that cannot be expressed with an interface (such as Union types).

By following this guideline, you can maintain application performance and scalability for the long term while benefiting from TypeScript's powerful type system.

Please remember that while the clarity of type on hover is appealing, it can potentially come at the cost of performance issues that can plague your project.

I hope this article brings a conclusion to the type vs interface debate and helps improve your developer experience.

GitHubで編集を提案

Discussion

shu-saginoyashu-saginoya

即時評価と遅延評価の解説がとてもためになりました。
ただこの場合、規模が小さければtype統一でもよいのかな?と感じました。
パフォーマンスの問題がなければ、冒頭の「分かりやすさ」というメリットはあるわけですし。

ロコペリロコペリ

Typescriptのハンドブックにも「typeの機能が必要になるまではinterfaceを使った方が良い」とあるので、基本的にはinterfaceを使用し、typeでしか実現できない表現があれば局所的にtypeを使用する方針が良さそうですね。

Differences Between Type Aliases and Interfaces

ネオPotatoネオPotato

本記事をきっかけにGeminiと深く議論しました。

TypeScriptという名前から type が主役かと思いきや、記事が指摘するパフォーマンス(extends)などの実態を見ると、静的言語における**『契約』の主役は interface** であり、type は(Union型やタプル型など)むしろ struct(データ構造)に近い役割を担っているのでは、という結論になりました。

この言語の『名前』と『設計思想』のねじれが、interface vs type 論争の根底にあるのかもしれませんね。非常に興味深い記事でした。