Types vs Interfaces: When each shines
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:
- Do we need a union, tuple, primitive alias, or mapped/conditional type? →
type - Do we need to augment third-party or ambient types (now or later)? →
interface - Are we designing a public library API that consumers might extend? → Prefer
interface - Are we modeling an internal app entity that may grow into variants? → Start with
type - Are we unsure? Default to
typefor data shapes; switch tointerfacewhen augmentation becomes a requirement.
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:
type → interface (to allow merging)
// before
export type Config = { baseUrl: string };
// after
export interface Config {
baseUrl: string;
}
interface → type (to unlock unions)
// before
interface User {
id: string;
name: string;
}
// after
type User = { id: string; name: string };
type Actor = User | { service: "bot" };
Subleties & Gotchas
-
Both support
extends/implements.interface Admin extends User {}andtype Admin = User & { … }are equivalent until you need merging/augmentation. -
Name shadowing: Re-declaring an
interfacemerges; re-declaring atypeconflicts. -
satisfieskeeps values and types aligned (great withtype):
const routes = {
home: "/",
user: (id: string) => `/u/${id}`,
} as const;
type RouteMap = {
home: string;
user: (id: string) => string;
};
const _ok = routes satisfies RouteMap;
-
Performance/emit: No runtime emit differences—both erase. Choose by DX and evolvability, not perf myths.
-
Overloads:
interfaceoverloads are tidier;typecan emulate via intersections but gets noisy. -
Module augmentation requires
interface. If you discover you need it later, convert the objecttypeto aninterface(usually a safe refactor).
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.