David Dunn

Senior Software Developer


Back to Blog

Types vs Interfaces: When each shines

7 min read
typescript

Pragmatic rules, real-world examples, and a decision flow for choosing between type aliases and interface declarations in TypeScript.

type User = { id: string; name: string };

interface IUser {
  id: string;
  name: string;
}

class Person implements User {}

class Person2 implements IUser {}

These are the same right? Well, yes and no.

In a project both of the options above, type and interface are perfectly valid syntax but there’s some magic happening behind the scenes which impact how these two types are used throughout a codebase.

The type Keyword

Using type shines in situations where a new type needs to be composed of a series of smaller types. Unions, intersections, mapped types and conditional types are all examples of this.

Unions

// Union of 3 string literal types
type Status = "idle" | "loading" | "error";

One of the most common ways to create types and ensures that any value that is of type Status can only be one of the provided string literals. The Status type reads as: Can be "idle" or "loading" or "error".

Unions are not limited to string literals, all of the following are valid:

type ID = string | number;

type Result<T> = { ok: true; data: T } | { ok: false; error: string };

This example uses Generics, a powerful feature in TypeScript, I have a post on Generics if you’d like to know more!

Intersections

type WithTimestamps = { createdAt: Date; updatedAt: Date };
type User = { id: string; name: string } & WithTimestamps;

// user = {id: string; name: string; createdAt: Date; updatedAt: Date };

Another very common way to create types in TypeScript and unlike union, where it is one or the other, an intersection merges the types into a new type containing all properties from both types.

Mapped Types

type Optional<T> = { [K in keyof T]?: T[K] };

type User = { id: string; name: string; age?: number };
type UserDraft = Optional<User>; // Makes all properties of User optional

Mapped types allow us to map over the properties of one type and modify them in some way or just remove them altogether. Mapped types and conditional types work really well together to modify the properties of one type based on some condition.

Conditional Type

type IsString<T> = T extends string ? true : false;
type A = IsString<"hi">; // true
type B = IsString<42>; // false

A simple example allows us to see how conditionals can be used to operate on values to return some new type. The real power of conditionals is unlocked when, as mentioned above, we combine them with mapped types.

Conditional + Mapped Types

type Create<T> = {
  [K in keyof T as K extends "id" | "createdAt" | "updatedAt"
    ? never
    : K]-?: T[K];
};

type Update<T> = Partial<Create<T>>;

type User = {
  id: string;
  name: string;
  email: string;
  createdAt: Date;
  updatedAt: Date;
};

type CreateUser = Create<User>; // { name: string; email: string }
type UpdateUser = Update<User>; // { name?: string; email?: string }

If this all looks a little confusing then you’re not alone! I’ve created a post that dives into Conditional and Mapped Types in more detail

The Create type has combined both conditional and mapped types to remove particular fields, “id”, “createdAt” and “updatedAt” in this case, from any provided type. This mimics the built in utility type, Omit provided in TypeScript. The Update type uses the Partial utility type to make all properties of the provided type optional.

So we have a User type that may represent how a user looks within our Database. The CreateUser type is created from the Create type and may represent a payload within our POST route for creating a user. Similarly the UpdateUser type can be used for our PATCH route to update a user.

This is the power of compostion within types created by the type keyword. From a single source of truth, the User type, we’re able to compose many other types for our application.

The interface keyword

Augmenting declarations

declare global {
  interface Window {
    dataLayer?: unknown[];
  }
}

Augments the built-in global Window type with a new dataLayer property. This allows us to do window.dataLayer within our application in a type-safe way.

Extending object contracts

type Spell = {
  id: string;
  name: string;
  type: "fire" | "water" | "lightning" | "air";
  damage: number;
  cost: number;
};

interface BaseClass {
  id: string;
  name: string;
  weapon: string;
  armor: string;
}

interface Mage extends BaseClass {
  mana: number;
  availableSpells: Spell[];
}

// mage = { id: string; name: string; weapon: string; armor: string; mana: number; availableSpells: Spell[] }

Using a syntax similar to how classes extend eachother we can extend one interface with another. Extending an interface is the same, functionally, as intersecting a type but generally the interface syntax of extending is considered easier to read.

In the above example we also created a type, Spell, using the type keyword and then used that type within our interface, this is a very common practice. So it is important that we understand the choice isn’t using type OR interface within a project. Both type and interface can, and should, be used together.

Type vs Interface… The decider!

Great, so now we know more about the type and interface keywords but when do we actually use one or the other when creating a new type?

Well, like the answer to many questions, it depends.

A few questions we can ask ourselves to help find the answer are:

If the worst happens and we started using type but later found we needed an interface, or vice-versa, it isn’t a problem. In most cases it is very easy to switch from one to the other:

typeinterface (to allow merging)

// before
export type Config = { baseUrl: string };

// after
export interface Config {
  baseUrl: string;
}

interfacetype (to unlock unions)

// before
interface User {
  id: string;
  name: string;
}

// after
type User = { id: string; name: string };
type Actor = User | { service: "bot" };

Subleties & Gotchas

const routes = {
  home: "/",
  user: (id: string) => `/u/${id}`,
} as const;

type RouteMap = {
  home: string;
  user: (id: string) => string;
};

const _ok = routes satisfies RouteMap;

Conclusion

Types and interfaces overlap by design. Reach for type when composing; reach for interface when collaborating. If you’re designing for others, optimize for augmentation. If you’re shipping fast internally, optimize for expression.