Use interfaces for shared object shapes, and use types when you need unions, tuples, mapped types, or schema-derived data. That simple rule solves most TypeScript debates before they waste half a sprint. TypeScript’s own docs show both tools as ways to describe object shapes, but they differ once extension, unions, and aliasing enter the picture [source: TypeScript].
TLDR: In React, interface is usually great for component props because it is easy to extend. In Zod, type often wins because z.infer creates a type from your schema, such as type User = z.infer<typeof UserSchema>. For API work, a team with 30 endpoints might use interfaces for request and response objects, then types for status unions like "draft" | "published". If your app has 50+ shared models, this split can cut repeat definitions and annoying refactors fast.
Interfaces are best when an object has a name and a job
An interface describes the shape of an object. That sounds boring, but it is exactly what you want for many app models.
interface User {
id: string;
email: string;
name: string;
}
This reads like a contract. Any object called User must have those fields. It also extends cleanly.
interface AdminUser extends User {
role: "admin";
permissions: string[];
}
This is where interfaces feel natural. You start with a base shape. Then you add more fields for a special case. It is simple. It is readable. Your future self will not hate it at 4:58 p.m.
Types are better when the shape is not enough
A type can also describe an object.
type User = {
id: string;
email: string;
name: string;
};
So why use it? Because type can express things an interface cannot express as neatly.
- Unions:
type Status = "idle" | "loading" | "error" - Tuples:
type Point = [number, number] - Mapped types:
type ReadonlyUser = Readonly<User> - Intersections:
type Staff = User & { department: string }
Honestly, it feels like people argue about this more than they need to. If you are naming a plain object contract, reach for interface. If you are composing types, creating unions, or using utility types, reach for type.
React example: props usually fit interfaces
React props are a perfect real-world case. A component expects an object. That object has fields. An interface fits well.
interface ButtonProps {
label: string;
disabled?: boolean;
onClick: () => void;
}
function Button({ label, disabled, onClick }: ButtonProps) {
return (
<button disabled={disabled} onClick={onClick}>
{label}
</button>
);
}
Now say you have a primary button with everything from ButtonProps, plus a tracking key.
interface PrimaryButtonProps extends ButtonProps {
trackingId: string;
}
That is clean. No noise. No fancy tricks.
But types are still useful in React. For example, state values often need unions.
type LoadState = "idle" | "loading" | "success" | "error";
Try doing that with an interface. You cannot, at least not in the same direct way. This is why strict “always use interface” rules get clumsy.
Zod example: types often come from schemas
Zod changes the conversation. With Zod, the schema is the source of truth. You define runtime validation first, then infer the TypeScript type from it. That is one of Zod’s best features [source: Zod].
import { z } from "zod";
const UserSchema = z.object({
id: z.string(),
email: z.string().email(),
age: z.number().min(18),
});
type User = z.infer<typeof UserSchema>;
Here, type is the obvious choice. You are not writing the model by hand. You are extracting it from the schema.
The catch is that teams sometimes define both:
interface User {
id: string;
email: string;
age: number;
}
const UserSchema = z.object({
id: z.string(),
email: z.string().email(),
age: z.number(),
});
That duplication is a bug waiting to happen. One field changes. The other field does not. Then someone loses 20 minutes wondering why the form passes TypeScript but fails validation.
API development: mix both on purpose
APIs need clear contracts. Request bodies, response bodies, and shared entities are usually good interface candidates.
interface CreateUserRequest {
email: string;
password: string;
}
interface CreateUserResponse {
id: string;
email: string;
createdAt: string;
}
These names tell a story. They also work well across backend code, frontend clients, and generated SDKs.
But API state, errors, and filters often work better as types.
type ApiResult<T> =
| { ok: true; data: T }
| { ok: false; error: string };
type SortOrder = "asc" | "desc";
This pattern shines in real apps. Your function can return a safe result without throwing everywhere.
async function getUser(id: string): Promise<ApiResult<User>> {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) {
return { ok: false, error: "User not found" };
}
return { ok: true, data: await res.json() };
}
Now TypeScript helps you handle both cases. No guessing. No random undefined crash two screens later.
The annoying part: interface merging
Interfaces can be reopened and merged.
interface Window {
analyticsId: string;
}
interface Window {
featureFlags: string[];
}
TypeScript combines them. This is useful for library authors and global objects. It can also be irritating in app code. Expect to waste time on strange type changes if two files quietly add fields to the same interface name.
Types do not merge like that. Once a type alias exists, you cannot declare it again with the same name in the same scope. That strictness can be a relief.
A practical rule for teams
- Use
interfacefor public object contracts, component props, DTOs, and models meant to be extended. - Use
typefor unions, inferred Zod schemas, utility types, tuples, and mixed result shapes. - Do not duplicate Zod schemas and manual interfaces unless you enjoy quiet bugs.
- Pick one style per case so reviews do not turn into naming debates.
The real answer is not “interfaces vs types.” It is which one makes the contract clearer. React props often want interfaces. Zod outputs usually want types. API code wants both. Use each where it fits, and your TypeScript code will feel less fussy and far easier to maintain.




