In the previous tutorial, we learned about objects and interfaces. Now let’s learn about union types, literal types, and type aliases — patterns that make TypeScript truly powerful.
By the end of this tutorial, you will know how to use union types, literal types, discriminated unions, intersection types, and type aliases.
Union Types
A union type means “this value can be one of several types.” Use the | (pipe) symbol:
let id: string | number;
id = "abc-123"; // OK — string
id = 42; // OK — number
id = true; // Error: Type 'boolean' is not assignable to type 'string | number'
Union types are everywhere in real code. A function that accepts multiple types:
function printId(id: string | number): void {
console.log(`ID: ${id}`);
}
printId("abc-123"); // OK
printId(42); // OK
But there is a catch. When you have a union type, you can only use methods that exist on all types in the union:
function printId(id: string | number): void {
console.log(id.toUpperCase());
// Error: Property 'toUpperCase' does not exist on type 'number'
}
To use type-specific methods, you need narrowing. We will cover narrowing in detail in Tutorial #8. For now, here is a quick example:
function printId(id: string | number): void {
if (typeof id === "string") {
console.log(id.toUpperCase()); // OK — TypeScript knows id is string here
} else {
console.log(id.toFixed(2)); // OK — TypeScript knows id is number here
}
}
Literal Types
A literal type is a type that represents one exact value. Instead of “any string,” you say “only this specific string”:
let direction: "up" | "down" | "left" | "right";
direction = "up"; // OK
direction = "down"; // OK
direction = "north"; // Error: Type '"north"' is not assignable
Literal types work with numbers and booleans too:
let httpStatus: 200 | 404 | 500;
httpStatus = 200; // OK
httpStatus = 201; // Error: Type '201' is not assignable
This is very useful for function parameters that only accept specific values:
function setAlignment(align: "left" | "center" | "right"): void {
console.log(`Alignment set to: ${align}`);
}
setAlignment("center"); // OK
setAlignment("top"); // Error: Argument of type '"top"' is not assignable
Type Aliases
A type alias gives a name to a type. Use the type keyword:
type ID = string | number;
let userId: ID = "abc-123";
let orderId: ID = 42;
Type aliases make your code cleaner and more readable. Compare these:
// Without type alias — hard to read
function processOrder(
userId: string | number,
orderId: string | number,
status: "pending" | "shipped" | "delivered"
): void { /* ... */ }
// With type aliases — much cleaner
type ID = string | number;
type OrderStatus = "pending" | "shipped" | "delivered";
function processOrder(userId: ID, orderId: ID, status: OrderStatus): void {
/* ... */
}
You can use type aliases for object types too:
type User = {
name: string;
age: number;
email: string;
};
const user: User = {
name: "Alex",
age: 25,
email: "alex@example.com",
};
Remember from Tutorial #5: use interface for objects when you might extend them. Use type for unions, tuples, and other combinations.
Discriminated Unions
Discriminated unions are one of the most important patterns in TypeScript. They combine union types with literal types to create type-safe variants.
The idea: each variant has a common property (the discriminant) with a unique literal value. TypeScript uses this property to tell the variants apart.
type Circle = {
kind: "circle";
radius: number;
};
type Rectangle = {
kind: "rectangle";
width: number;
height: number;
};
type Triangle = {
kind: "triangle";
base: number;
height: number;
};
type Shape = Circle | Rectangle | Triangle;
The kind property is the discriminant. Each shape has a different value for kind.
Now you can use a switch statement to handle each variant:
function getArea(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
case "triangle":
return (shape.base * shape.height) / 2;
}
}
const circle: Circle = { kind: "circle", radius: 5 };
console.log(getArea(circle)); // 78.54
Inside each case, TypeScript knows the exact type. In case "circle", it knows shape is a Circle, so shape.radius is available.
Why Discriminated Unions Matter
Discriminated unions are better than using if/else chains or instanceof checks because:
- TypeScript checks that you handle every variant
- Adding a new variant shows errors everywhere you forgot to handle it
- Each variant can have completely different properties
This pattern is similar to Rust’s enum and Go’s interface patterns. If you come from those languages, you will feel at home.
Real-World Example: API Response
type SuccessResponse = {
status: "success";
data: { id: number; name: string }[];
total: number;
};
type ErrorResponse = {
status: "error";
message: string;
code: number;
};
type LoadingResponse = {
status: "loading";
};
type ApiResponse = SuccessResponse | ErrorResponse | LoadingResponse;
function handleResponse(response: ApiResponse): void {
switch (response.status) {
case "success":
console.log(`Got ${response.total} items`);
response.data.forEach((item) => console.log(item.name));
break;
case "error":
console.log(`Error ${response.code}: ${response.message}`);
break;
case "loading":
console.log("Loading...");
break;
}
}
Intersection Types
An intersection type combines multiple types into one. Use the & symbol:
type HasName = {
name: string;
};
type HasEmail = {
email: string;
};
type Contact = HasName & HasEmail;
const contact: Contact = {
name: "Alex",
email: "alex@example.com",
};
A Contact must have both name and email. It is the intersection (combination) of both types.
Intersection vs Union
- Union (
A | B): the value is either A or B - Intersection (
A & B): the value is both A and B
type Admin = {
name: string;
role: "admin";
};
type HasPermissions = {
permissions: string[];
};
// Intersection — must have ALL properties from both types
type SuperAdmin = Admin & HasPermissions;
const superAdmin: SuperAdmin = {
name: "Alex",
role: "admin",
permissions: ["read", "write", "delete"],
};
Intersections are useful for mixing in extra properties without inheritance.
Union Types vs Enums
TypeScript has both union types and enums. Which should you use?
// Union type (string literal)
type Status = "active" | "inactive" | "pending";
// Enum
enum StatusEnum {
Active = "active",
Inactive = "inactive",
Pending = "pending",
}
Modern TypeScript prefers union types over enums for most cases:
| Feature | Union Types | Enums |
|---|---|---|
| Runtime code | No (erased) | Yes (generates JavaScript) |
| Tree-shaking | Free | May not tree-shake |
| Autocompletion | Yes | Yes |
| Type safety | Yes | Yes |
Simple rule: Use union types when you have a small set of string values. Use enums when you need numeric values or reverse mapping. We will cover enums in detail in Tutorial #7.
Nullable Types
Union types are how TypeScript handles null and undefined:
function findUser(id: number): { name: string } | null {
if (id === 1) {
return { name: "Alex" };
}
return null;
}
const user = findUser(1);
if (user) {
console.log(user.name); // OK — TypeScript knows user is not null here
}
This is much safer than languages where any value can be null without the type system knowing.
Quick Practice
Create src/unions.ts and try these:
// Type aliases
type ID = string | number;
type Status = "active" | "inactive" | "pending";
// Discriminated union
type Dog = { kind: "dog"; breed: string };
type Cat = { kind: "cat"; indoor: boolean };
type Bird = { kind: "bird"; canFly: boolean };
type Pet = Dog | Cat | Bird;
function describePet(pet: Pet): string {
switch (pet.kind) {
case "dog":
return `Dog — breed: ${pet.breed}`;
case "cat":
return `Cat — ${pet.indoor ? "indoor" : "outdoor"}`;
case "bird":
return `Bird — ${pet.canFly ? "can fly" : "cannot fly"}`;
}
}
// Intersection type
type HasId = { id: ID };
type HasStatus = { status: Status };
type Entity = HasId & HasStatus & { name: string };
const entity: Entity = {
id: 1,
status: "active",
name: "Project Alpha",
};
// Test it
const pets: Pet[] = [
{ kind: "dog", breed: "Golden Retriever" },
{ kind: "cat", indoor: true },
{ kind: "bird", canFly: true },
];
pets.forEach((pet) => console.log(describePet(pet)));
console.log(`Entity: ${entity.name} (${entity.status})`);
Run it:
npx tsx src/unions.ts
Output:
Dog — breed: Golden Retriever
Cat — indoor
Bird — can fly
Entity: Project Alpha (active)
What is Next?
In the next tutorial, we will learn about enums and const assertions — when to use them and when union types are a better choice.
Related Articles
- TypeScript Tutorial #5: Objects and Interfaces — previous tutorial in this series
- Rust Tutorial: Enums and Pattern Matching — how Rust handles variants with enums and match