These types are not the same

When working with TypeScript types, the design of our types is important. One choice we often see is to define an interface, but many times a union is preferable to a single interface. Here are two types that can be used in similar ways, below I’ll explain why the union is often preferable:

 
TypeScript
interface SessionDataMega {
isLoggedIn: boolean;
user?: { name: string };
}
type SessionDataUnion =
| { isLoggedIn: false; user?: never; }
| { isLoggedIn: true; user: { name: string }; };

SessionDataMega is a wider type. It’s flexible enough to cover all the different shapes of data we expect — and some we don’t!

SessionDataUnion is a union of two narrower types. The union consists of two types (members of the union) that are both more specific than the interface.

The union is a better representation for our application and has some benefits. I hope we learn to leverage the power of unions more. ✊ Let’s unionize! ✊

We’ll talk about wide and narrow with regards to types, and the act of narrowing. A wider type is less constrained than a narrower type, more values fit when into a wider type, just like an alley. Narrowing is when tooling analyzes code and determine that in a given section of code a type is narrower.

One note: I say “megatype” here. That’s something I made up! It doesn’t have a technical definition. I’m referring to an interface that isn’t designed for something specific but is widened with optional properties until it satisfies all the different shapes that we want it to satisfy. That’s our SessionDataMega in this example.

Unions can have an arbitrary number of members, and when any member of the union is satisfied, the union is satisfied. So:

 
TypeScript
type SillyUnion = 'A' | 1 | null | Symbol ;
// These are all fine:
const sillyValues: SillyUnion[] =
['A', 1, null, Symbol('anything')];
// These are all bad:
const notSillyValues: SillyUnion[] =
['b', -1, undefined, {}]; // Errors!

Why is this important?

The types we chose to represent our application impacts the developer experience and the support the type system can provide. The union better represents the behavior of our application and will perform better when producing and working with data.

Producing values

The data we’re describing here is about a user. This may be a logged-in session, in which case we have a user. Or, it may be a logged-out session, in which case there is no user.

Our expected data satisfies both of our types:

 
TypeScript
const loggedInUser = {
isLoggedIn: true,
user: { name: 'example' },
} satisfies SessionDataMega satisfies SessionDataUnion;
const loggedOutUser = {
isLoggedIn: false,
} satisfies SessionDataMega satisfies SessionDataUnion;

But what if we do something strange, like have a logged-in session with no user? Or a logged-out session with a user? Well, our megatype is perfectly happy 😭

 
TypeScript
const badUserStateA = {
isLoggedIn: false,
user: { name: 'example' },
} satisfies SessionDataMega;
const badUserStateB = {
isLoggedIn: true,
} satisfies SessionDataMega;

On the other hand, our union correctly detects that this is a problem and flags it for us. That’s great 👏

 
TypeScript
const badUserStateC = {
isLoggedIn: false,
user: { name: 'example' },
} satisfies SessionDataUnion;
// Type … is not assignable to type '{ isLoggedIn: true; user: { name: string; }; }'.
const badUserStateD = {
isLoggedIn: true,
} satisfies SessionDataUnion;
// Property 'user' is missing in type '{ isLoggedIn: true; }' but required in type '{ isLoggedIn: true; user: { name: string; }; }'.

So what’s going on here? The union has two discrete types, and each of them had a boolean literal true or false. It describes in the type system our expectation that a logged in session will have a user, and a logged out session will not. Those two pieces of information are linked in each member of the union. It’s trivial for the compiler to see that invalid data like { isLoggedIn: true /* no user */ } does not satisfy our type.

However the megatype doesn’t consider variants, it’s just a single interface. isLoggedIn is a boolean (true or false) and we may or may not have a user 🤷. This is completely accurate, it’s just a description that doesn’t leverage the system’s expressiveness to encode and enforce our invariants.

Working with data

What about when we have one of these types and we need to use it? Let’s see how it works. We’ll write this really important yellAs function and use it to announce to the world that we’re logged-in in a block along with a bunch of other logged-in concerns:

 
TypeScript
/** A really helpful function for yelling a message as a person */
function yellAs(name: string, message: string,): string {
return `${name.toUpperCase()} SAYS "${message.toUpperCase()}"`
}
const sessionMega: SessionDataMega = loggedInUser;
if (sessionMega.isLoggedIn) {
// Do a bunch of logged-in stuff here!
// Our invariants suggests we should always have a name here, but the types say it's optional
// vvvvv
console.log(yellAs(sessionMega.user?.name ?? "who knows…", "great, I'm logged in"));
// ^^^^^^^^^^^^^^^^
// I guess add a fallback?
}

Well, that was frustrating 😕 We should have a user, but we’re fighting with the type system. There are a bunch of alternatives we could look at such as:

  • add another falsy-check condition for sessionMega.user ⛔️ that’s redudundant, isn’t it?
  • write a type guard that knows a logged-in session has a user ⛔️ that may have some risk shouldn’t be necessary
  • just use a type assertion because we know what’s going on ⛔️ assertions are very risky, avoid them!

(see some of these options in the playground)

We don’t have a good way of addressing the problem. The real problem is that our megatype isn’t a good description of what we expect 😖

Back to the drawing board

How about with our union type?

 
TypeScript
const sessionUnion: SessionDataUnion = loggedInUser;
if (sessionUnion.isLoggedIn) {
console.log(yellAs(sessionUnion.user.name, "great, I'm logged in"));
}

…that was easy.

This works because we leverage narrowing on our union type. One of the advantages of a union like this is that it’s very easy to narrow. Inside the conditional block, TypeScript knows our type exactly. It’s no longer the full union SessionDataUnion, but has been narrowed and TypeScript sees that it is exactly { isLoggedIn: true; user: { name: string; }; }.

Discriminated unions

Our union type is a special kind of union called a discriminated union. That’s a fancy way of saying it’s a union type with a property that is unique to each member of the union. In this case, we have two members and one has { isLoggedIn: true } while the other has { isLoggedIn: false }. We can look at this single property to know which specific member of the union we’re working with.

The takeaway

Too often we think in megatypes. If we learn to think about larger types as unions of more specific members and design our types carefully we can improve the developer experience for our applications.

Explore these concepts in the TypeScript playground.

This is a public adaptation of a internal post I wrote for Automattic. If you’re reading this, maybe you’d like to work with us 🙂

Comments

Leave a Reply

Discover more from sirre.al

Subscribe now to keep reading and get access to the full archive.

Continue reading