Recommended resources
The freely available resources this lesson will direct you to.
Hello. Last time, you used Promises to coordinate asynchronous work and handled the possibility that a request might succeed or fail. The next question is what those results—and the other data in your application—should look like. Today you’ll use TypeScript interfaces, unions, and type narrowing to describe project data and the events that change it.
Allow about 40 minutes, including the readings and short video. The aim is not just to annotate JavaScript: it is to make valid cases easy to handle and invalid combinations difficult to write.
Give domain objects a shape
Suppose an application stores projects. Every project has an ID, an owner ID, and a title; some also have a description. An interface names that object shape:
interface Project {
id: string;
ownerId: string;
title: string;
description?: string;
}
const project: Project = {
id: "project-1",
ownerId: "account-1",
title: "Portfolio site",
};
The ? means description may be absent. Reading it therefore gives you a value that could be undefined, so code using it must account for that possibility:
function projectSummary(project: Project): string {
if (project.description !== undefined) {
return `${project.title}: ${project.description}`;
}
return project.title;
}
TypeScript checks the shape: omitting title, for example, is a type error. It does not require an object to have been explicitly created as a Project; TypeScript primarily checks whether its properties have the required structure.
Read the relevant parts of the TypeScript Handbook’s Everyday Types before moving from one object shape to several possible shapes.
TypeScript: Documentation - Everyday Types
Read the TypeScript Handbook's explanation of object types, unions, type aliases, and interfaces. These are the building blocks for the project model in this lesson.
Start in “Object Types” and continue through “Optional Properties,” from object shapes. Under “Union Types,” read “Defining a Union Type” and “Working with Union Types,” from union values. Then read “Type Aliases” and “Interfaces,” including the paragraph immediately below the interface example about structural typing. You can stop before “Differences Between Type Aliases and Interfaces.”
An interface is a useful name for an object shape. A type alias can also name an object shape, but it can additionally name a union. You will use both shortly. Neither declaration creates a runtime object or checks incoming data by itself; these types guide the checker while you write and compile code.
A union means “one of these”
Imagine that most project IDs are strings, but a migration routine also accepts older numeric IDs. A union expresses that input without pretending it is always one type:
type ProjectId = string | number;
function normalizeProjectId(id: ProjectId): string {
if (typeof id === "number") {
return id.toString();
}
return id.trim();
}
Before the if, id could be either member of the union. Calling id.trim() there would be an error because numbers do not have that method. The typeof check is a type guard: in its branch TypeScript treats id as a number, and after that branch returns, it knows the remaining value is a string. This refinement based on possible paths through the code is called type narrowing.
The check is ordinary JavaScript that runs at runtime. TypeScript uses that check during type analysis; it does not insert a separate check for you. For other unions, Array.isArray(value) can distinguish an array from a non-array value, while a check such as "projectId" in event can help distinguish object shapes. A property-presence check needs care if the property is optional: its absence might not rule out the type that declares it.
Model distinct events as distinct shapes
Now consider changes to a project. A creation event needs the new project. A rename needs an existing project ID and its new title. A deletion needs a project ID, but no new title.
A single interface with an eventType field and several optional payload fields would be permissive in the wrong places. It could describe a creation with no project, or a deletion carrying a new title. Before looking at the solution, watch how a similar problem arises when one type tries to describe several application states.
This Is One Of My Favorite TypeScript Features
Watch This Is One Of My Favorite TypeScript Features by Web Dev Simplified for its example of replacing loosely related optional fields with a discriminated union.
Watch the state refactor. Focus on why separate variants make each relevant field required, and how checking a shared literal-valued property tells TypeScript which variant is present. Stop before the later discussion of the technique's limitations.
We can apply the same design to project events. Each interface has a shared kind property, but gives it a different string literal type:
interface ProjectCreated {
kind: "created";
project: Project;
}
interface ProjectRenamed {
kind: "renamed";
projectId: string;
newTitle: string;
}
interface ProjectDeleted {
kind: "deleted";
projectId: string;
}
type ProjectEvent =
| ProjectCreated
| ProjectRenamed
| ProjectDeleted;
ProjectEvent is a discriminated union. The kind field is its discriminant: knowing its value identifies which shape you have. For example, TypeScript rejects a ProjectCreated value with no project, and it does not let you read newTitle from every ProjectEvent—only rename events have that property.

The Handbook works through the reasoning behind this pattern using shapes. Read its example as a model for the project events above, rather than copying its geometry into your application.
TypeScript: Documentation - Narrowing
Read the TypeScript Handbook's treatment of discriminated unions and exhaustive handling. It explains why a shared tag plus optional fields is weaker than a union of separate, required shapes.
In “Discriminated unions,” start at the shape example. Pay particular attention to the first Shape interface with optional measurements, its replacement by Circle | Square, and the switch in getArea. Then read “The never type” and “Exhaustiveness checking”: start at the exhaustiveness explanation and continue through the immediately following Triangle example, where adding a variant produces an error.
Narrow an event before using its payload
A switch on kind lets each branch use precisely the properties guaranteed for that event:
function describeEvent(event: ProjectEvent): string {
switch (event.kind) {
case "created":
return `Created ${event.project.title}`;
case "renamed":
return `Renamed ${event.projectId} to ${event.newTitle}`;
case "deleted":
return `Deleted ${event.projectId}`;
default:
const unreachable: never = event;
throw new Error(
`Unknown project event: ${JSON.stringify(unreachable)}`
);
}
}
Within "created", event is a ProjectCreated; within "renamed", it is a ProjectRenamed. The default branch adds an exhaustiveness check. Once every declared variant has been handled, TypeScript considers event there to have type never—no remaining possibility. If you later add another member to ProjectEvent but forget its case, assigning that member to never becomes a compile-time error.
The thrown error serves a different purpose: it gives the running program a response if an unexpected value reaches that branch. The type system cannot guarantee that external data follows your declarations. In particular, writing incomingData as ProjectEvent would tell TypeScript to trust you; it would not verify the data. For now, use these types to model values your application constructs and handles. A later lesson will cover validating untrusted data before treating it as typed application data.
Key takeaways
Use an interface to name a domain object’s required and genuinely optional fields. Use a union when a value can have different types or shapes, and narrow it with checks before accessing properties that only some members have. When object variants represent distinct cases, give each a shared literal-valued tag and its own required payload; an exhaustive switch can then alert you when the model grows.
Next, you’ll build on these concrete types to write reusable TypeScript functions with constrained generics.