TypeScript unions and discriminating unions
A union type says a value is one of several shapes. That is exactly how application state works: a request is loading, or it failed, or it has data. So I reach for unions constantly. The catch is that a bare union only lets you touch the fields every member shares. This is how one literal field fixes that and makes the compiler narrow the type for you.
A union only exposes its shared fields
Model a user-loading state as three interfaces, then a union over them:
interface UserLoadFailed {
statusCode: number;
message: string;
}
interface UserLoading {
statusCode: number;
isLoading: boolean;
}
interface UserLoaded {
statusCode: number;
data: User;
}
type UserState = UserLoadFailed | UserLoading | UserLoaded;
Now consume it:
declare function getUserState(): UserState;
const user = getUserState();
user.statusCode; // fine: every member has statusCode
user.data; // error: only UserLoaded has data
statusCode is reachable because it lives in all three members, and that intersection
is the only thing TypeScript lets you touch on the bare union. Reach for data and the
compiler stops you: it can’t prove you aren’t holding a UserLoadFailed.
Add a literal field to discriminate
Give every member a status field typed as a string literal. That one field is the
discriminant:
interface UserLoadFailed {
status: "failed";
statusCode: number;
message: string;
}
interface UserLoading {
status: "loading";
}
interface UserLoaded {
status: "loaded";
data: User;
}
type UserState = UserLoadFailed | UserLoading | UserLoaded;
Check that field and TypeScript narrows the union to the one member that matches:
function render(user: UserState): string {
switch (user.status) {
case "failed":
return `error ${user.statusCode}: ${user.message}`;
case "loading":
return "loading…";
case "loaded":
return user.data.name; // data is reachable: narrowed to UserLoaded
default:
return assertNever(user); // compile error if a state goes unhandled
}
}
function assertNever(x: never): never {
throw new Error(`unhandled state: ${JSON.stringify(x)}`);
}
Inside each case, user is the specific member, so its unique fields are reachable and
type-safe. The default branch is where exhaustiveness pays off: assertNever accepts a
never, so if you add a fourth state and forget a case, user is no longer never
there and the build fails. The states become impossible to drop silently.
The TypeScript handbook goes deeper on unions and intersections.