The TypeScript interface vs type question has a short answer. Both can describe the shape of an object, and for most object types either works. Interfaces can be reopened and merged, and they extend with extends. Type aliases can name any type, including unions, tuples, primitives and mapped types. A practical rule: use interface for object shapes, especially public ones, and type for everything else. Whichever you pick, be consistent.
This guide covers that choice alongside the other compound types you use daily: arrays and tuples, enums, excess property checks, function types, overloads and type guards. Examples target TypeScript 5.x.
Arrays and tuples
An array holds any number of elements of one type. The two syntaxes are equivalent:
const tags: string[] = ["ts", "node"];
const scores: Array<number> = [90, 85];
const tenth = tags[10]; // typed as string, but undefined at runtimeReading past the end of an array returns undefined at runtime, yet TypeScript types it as string. Enable noUncheckedIndexedAccess if you want index reads typed as string | undefined.
A tuple is an array with a fixed length and a known type at each position:
let point: [number, number] = [10, 20];
point = [10, 20, 30]; // Error: source has 3 elements but target allows only 2
const z = point[2]; // Error: tuple of length 2 has no element at index 2Labeled tuples document what each position means, and support optional and rest elements:
type Range = [start: number, end: number];
type HttpResult = [status: number, body: string, headers?: Record<string, string>];
type Path = [root: string, ...segments: string[]];One gotcha: a mutable tuple still has array methods, so point.push(30) compiles and breaks the fixed length. Mark tuples readonly when they shouldn't change:
const origin: readonly [number, number] = [0, 0];
origin.push(1); // Error: property 'push' does not existUse tuples for short positional data such as coordinates or a hook's return value. Beyond three fields, an object is easier to read.
Enums and their alternatives
An enum defines a named set of constants. Numeric enums auto-increment from 0 and get a reverse mapping:
enum Direction { Up, Down, Left, Right }
Direction.Up; // 0
Direction[0]; // "Up"String enums are more readable in logs and API payloads:
enum OrderStatus {
Pending = "PENDING",
Paid = "PAID",
Shipped = "SHIPPED",
}
function ship(status: OrderStatus) { /* ... */ }
ship(OrderStatus.Paid); // OK
ship("PAID"); // Error: '"PAID"' is not assignable to 'OrderStatus'Enums are one of the few TypeScript features that generate runtime code. That's why they don't work with erasable-syntax setups such as Node.js's built-in type stripping or the erasableSyntaxOnly compiler option added in TypeScript 5.8. Many teams prefer a union of literals or an as const object, which give the same autocomplete with plain JavaScript underneath:
const OrderState = {
Pending: "PENDING",
Paid: "PAID",
Shipped: "SHIPPED",
} as const;
type OrderState = (typeof OrderState)[keyof typeof OrderState];
// "PENDING" | "PAID" | "SHIPPED"
function ship2(status: OrderState) { /* ... */ }
ship2("PAID"); // OK
ship2(OrderState.Paid); // OKInterfaces
An interface names an object shape. Properties can be optional (?) or readonly:
interface User {
readonly id: string;
name: string;
email?: string;
}
interface Admin extends User {
permissions: string[];
}
interface Timestamped {
createdAt: Date;
}
interface AuditedAdmin extends Admin, Timestamped {}An interface can extend several others, and a class can implements one so the compiler checks its members. readonly is compile-time only and doesn't freeze the object.
Type aliases
A type alias gives a name to any type, not only object shapes:
type ID = string | number; // union
type Point = { x: number; y: number }; // object
type Pair = [key: string, value: number]; // tuple
type Handler = (event: MouseEvent) => void; // function
type AdminUser = User & { permissions: string[] }; // intersectionUnions, tuples, function types, mapped types and conditional types can only be written with type. Advanced forms like mapped types are covered in TypeScript advanced types.
TypeScript interface vs type: the differences that matter
interface | type | |
|---|---|---|
| Object shapes | Yes | Yes |
| Unions, tuples, primitives, function types | No | Yes |
| Mapped and conditional types | No | Yes |
| Extending | extends, conflicts are errors | &, conflicts become never silently |
| Declaration merging | Yes, same-name declarations merge | No, duplicate identifier error |
implements in a class | Yes | Yes, if it's an object type (not a union) |
| Name in error messages and hovers | Shown by name | Sometimes expanded inline |
Two rows deserve an example.
Declaration merging. Two interfaces with the same name in the same scope merge into one. Type aliases can't be redeclared:
interface Settings { theme: string }
interface Settings { language: string }
const s: Settings = { theme: "dark", language: "th" }; // needs both
type Options = { theme: string };
type Options = { language: string }; // Error: duplicate identifier 'Options'Merging is how you add fields to third-party types, such as a user property on Express's Request, as covered in TypeScript declaration files. The downside is that an interface can be extended by accident if another file uses the same global name.
Conflicts when extending. extends checks compatibility immediately. An intersection doesn't:
interface A { id: string }
interface B extends A { id: number }
// Error: interface 'B' incorrectly extends interface 'A'
type C = A & { id: number }; // no error, but C["id"] is neverThe TypeScript performance guidance also prefers extends over large intersections, because the compiler caches relationships between named interfaces.
The practical rule: use interface for object shapes that other code builds on, such as component props, API models and library contracts. Use type for unions, tuples, function types and anything computed from other types. If your codebase already follows another convention consistently, keep it.
Excess property checks
When you assign an object literal directly to a typed variable or parameter, TypeScript rejects properties the type doesn't declare:
interface Point { x: number; y: number }
const p: Point = { x: 1, y: 2, z: 3 };
// Error: object literal may only specify known properties, and 'z' does not exist in type 'Point'This mainly catches typos in optional fields, which would otherwise be silently ignored:
function createUser(options: { name: string; email?: string }) { /* ... */ }
createUser({ name: "Ana", emial: "[email protected]" });
// Error: 'emial' does not exist. Did you mean to write 'email'?The check only applies to fresh object literals. If the object is first stored in a variable, extra properties are allowed, because structural typing only requires that the declared properties exist:
const raw = { x: 1, y: 2, z: 3 };
const p2: Point = raw; // OKIf a type should accept extra keys, declare an index signature.
Function types
TypeScript types parameters and return values in all three JavaScript function forms:
// function declaration
function add(a: number, b: number): number {
return a + b;
}
// anonymous function expression assigned to a variable
const multiply = function (a: number, b: number): number {
return a * b;
};
// arrow function
const subtract = (a: number, b: number): number => a - b;Parameters can be optional, have defaults, or collect the rest into an array:
function greet(name: string, greeting = "Hello", title?: string): string {
return title ? `${greeting}, ${title} ${name}` : `${greeting}, ${name}`;
}
function sum(...values: number[]): number {
return values.reduce((total, v) => total + v, 0);
}To describe a function as a type, for callbacks or strategy objects, use a function type expression:
type Comparator<T> = (a: T, b: T) => number;
const byPrice: Comparator<Product> = (a, b) => a.price - b.price;
products.sort(byPrice);Function overloading
Overloads let one function have several call signatures, each with its own return type. You write the overload signatures first, then one implementation that handles every case:
function getUser(id: string): User;
function getUser(ids: string[]): User[];
function getUser(idOrIds: string | string[]): User | User[] {
return Array.isArray(idOrIds) ? idOrIds.map(findById) : findById(idOrIds);
}
const one = getUser("u1"); // User
const many = getUser(["u1", "u2"]); // User[]The implementation signature isn't visible to callers, so a caller holding a string | string[] gets "No overload matches this call". Use overloads only when the return type depends on the arguments. Otherwise a single signature with a union parameter is simpler. Generic and conditional return types are the other option, covered in TypeScript generics.
Type guards
A type guard is a runtime check that TypeScript uses to narrow a type. The built-in ones are typeof, instanceof, in, Array.isArray and equality checks:
function describe(value: string | number | Date): string {
if (typeof value === "string") return value.trim();
if (value instanceof Date) return value.toISOString();
return value.toFixed(2); // number
}For your own types, write a function whose return type is a type predicate, value is Type:
type Fish = { swim(): void };
type Bird = { fly(): void };
function isFish(pet: Fish | Bird): pet is Fish {
return "swim" in pet;
}
const fish = pets.filter(isFish); // Fish[]An assertion function throws instead of returning a boolean, and narrows everything after the call:
function assertIsString(value: unknown): asserts value is string {
if (typeof value !== "string") throw new TypeError("Expected a string");
}
assertIsString(input);
input.toUpperCase(); // input is string from here onSince TypeScript 5.5, simple predicates are inferred automatically, so ["a", undefined, "b"].filter((id) => id !== undefined) is typed as string[] without an explicit id is string. The compiler trusts a predicate completely, so keep guards small and test them.
FAQ
Should I use interface or type in TypeScript?
Use interface for object shapes, especially ones that are extended or exported, and type for unions, tuples, function types and computed types. Both are fine for plain objects. Consistency matters more than the choice.
Can a class implement a type alias?
Yes, as long as the alias describes an object type or an intersection of object types. A class can't implement a union type.
Are TypeScript enums bad?
Not bad, but they generate runtime code and behave differently from the rest of the type system. Unions of string literals or as const objects cover most use cases with less surprise.
Why is there no excess property error when I pass a variable?
Excess property checks only apply to object literals written directly where the type is expected. A variable is checked structurally, so extra properties are allowed.
What is the difference between a tuple and an array?
An array has any length and one element type. A tuple has a fixed length with a specific type at each position, such as [number, string].
Checklist
- Use
interfacefor object shapes andtypefor unions, tuples and function types. - Mark tuples
readonlyand prefer objects once there are more than three fields. - Consider literal unions or
as constobjects before reaching forenum. - Let excess property checks catch typos, and use an index signature when extra keys are valid.
- Use overloads only when the return type depends on the arguments.
- Keep type guards small, since the compiler trusts them completely.
For values of unknown type, which are what type guards usually narrow, see any vs unknown. If your team wants a second pair of eyes on a TypeScript codebase or its types, Vectorkub can help.
