Composing Types
Annotations are not the only thing the type system gives you. You can give a type a name, you can combine two types with a union or an intersection, and you can use a specific value as a type. With these you can describe more complicated data, and the compiler still checks your code.
Type Aliases with type
A type alias gives a name to any type. Use the type keyword:
type Point = {
x: number;
y: number;
};
const origin: Point = { x: 0, y: 0 };
Type aliases work with any type — not just objects:
type ID = string | number;
type Callback = (data: string) => void;
type Pair = [string, number];
type vs interface
Both type and interface can describe object shapes, and in most cases they are interchangeable:
// Using interface
interface UserA {
name: string;
age: number;
}
// Using type alias
type UserB = {
name: string;
age: number;
};
The key differences:
| Feature | interface |
type |
|---|---|---|
| Object shapes | Yes | Yes |
| Extends / inheritance | extends keyword |
Intersection (&) |
| Declaration merging | Yes (can reopen) | No |
| Unions / intersections | No (directly) | Yes |
| Primitives, tuples, etc. | No | Yes |
Declaration merging means you can define the same interface twice, and TypeScript combines them:
interface Config {
debug: boolean;
}
interface Config {
version: string;
}
// Config now has both `debug` and `version`
const config: Config = { debug: true, version: "1.0" };
This is useful when you want to add to type definitions that come from a third-party package. It can also surprise you if you did not mean to merge the two declarations.
Union Types
A union type describes a value that can be one of several types. Use the | operator — think of it as “OR”:
type StringOrNumber = string | number;
let id: StringOrNumber;
id = 101; // OK
id = "abc-123"; // OK
id = true; // Error: Type 'boolean' is not assignable to type 'string | number'
A common pattern is a function that accepts multiple input types:
function formatId(id: string | number): string {
if (typeof id === "string") {
return id.toUpperCase();
}
return `#${id}`;
}
formatId("abc"); // "ABC"
formatId(42); // "#42"
Notice the typeof check inside the function. This is called type narrowing. TypeScript understands that inside the if block, id must be a string, so it allows string methods. We will cover narrowing in more detail in Type Narrowing and Runtime Behavior.
Literal Types
You can use a specific value as a type. Put several of them in a union, and you get a type that allows only those values:
type Direction = "north" | "south" | "east" | "west";
function move(direction: Direction) {
console.log(`Moving ${direction}`);
}
move("north"); // OK
move("up"); // Error: Argument of type '"up"' is not assignable
// to parameter of type 'Direction'
This does much the same job as an enum. It is lighter, and it is the more common choice in modern TypeScript.
Intersection Types
An intersection type combines multiple types into one. Use the & operator — think of it as “AND”:
type HasName = { name: string };
type HasAge = { age: number };
type Person = HasName & HasAge;
const ali: Person = { name: "Ali", age: 30 };
// must have both `name` AND `age`
Intersections are especially useful for composing types from smaller pieces:
type Timestamped = { createdAt: Date; updatedAt: Date };
type SoftDeletable = { deletedAt: Date | null };
type User = { name: string; email: string } & Timestamped & SoftDeletable;
A Note on Set Theory
The names “union” and “intersection” can be confusing at first. A union (|) makes the type wider, meaning more values are allowed. An intersection (&) makes it narrower, meaning more properties are required. The names come from set theory. Think of a type as the set of values it allows. The union of two sets holds the values from either set. The intersection holds only the values that satisfy all the constraints of both sets.