Advanced TypeScript: Type Algebra
Welcome to the Type Algebra lesson!
This lesson is shown as static text below. However, it's designed to be used interactively. Click the button below to start!
In primary school math classes, we learn the distributive law of arithmetic. For example, multiplication distributes over addition, so the
xinx * (y + z)distributes overy + z. We can rewrite it as(x * y) + (x * z).>
const [x, y, z] = [2, 3, 4];x * (y + z);Result:
14
>
const [x, y, z] = [2, 3, 4];(x * y) + (x * z);Result:
14
>
const [x, y, z] = [3, 4, 5];x * (y + z) === (x * y) + (x * z);Result:
true
However, if we change the expression to
x + (y * z), thenxdoesn't distribute. That is, addition doesn't distribute over multiplication. We can't rewrite that expression as(x + y) * (x + z).>
const [x, y, z] = [2, 3, 4];x + (y * z);Result:
14
>
const [x, y, z] = [2, 3, 4];(x + y) * (x + z);Result:
30
>
const [x, y, z] = [3, 4, 5];x + (y * z) === (x + y) * (x + z);Result:
false
Boolean operators are also distributive. For example, the
&&operator distributes like*. We can distributexover the||terms inx && (y || z), giving us the equivalent expression(x && y) || (x && z).TypeScript's
&(intersection) and|(union) types also follow algebraic laws, including the distributive law. Here, the distributive law is similar to the law that we know for numbers, but with one difference.As we saw above, we can only distribute
*across+. But with TypeScript types, we can distribute both the&and|operators.For example, the type
A & (B | C)is equivalent to(A & B) | (A & C). We've distributed theAover theB | C.If we swap the operators, we can still use the same rule. The type
A | (B & C)is equivalent to(A | B) & (A | C).Now we'll work through an example where this knowledge is very important. We have users and cats. Users have
isAdmin: booleanand cats haveisAdmin: undefined. Finally, we have anIsAdminobject type that only specifiesisAdmin: true.>
type User = {kind: 'user', name: string, isAdmin: boolean};type Cat = {kind: 'cat', name: string, isAdmin: undefined};type IsAdmin = {isAdmin: true};We'll intersect
IsAdminwith users and cats, then explore the resulting type.- Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
type Admin = IsAdmin & (User | Cat); Users are easier to analyze than cats, so we'll start there. They work as we'd hope: if they have
isAdmin: true, they're valid; but if they haveisAdmin: false, that's a type error.- Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
const admin: Admin = {kind: 'user', name: 'Amir', isAdmin: true};admin.isAdmin;Result:
true
- Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
const admin: Admin = {kind: 'user', name: 'Amir', isAdmin: false};admin.isAdmin;Result:
type error: Type 'false' is not assignable to type 'true'.
Why does this type require
isAdmin: true? It's becauseUserhasisAdmin: boolean, butIsAdminhasisAdmin: true. When we intersect the two types, we getisAdmin: boolean & true. Intersectingboolean & trueis equivalent to justtrue, becausetrueis a kind of boolean. The result is that users must haveisAdmin: true.For cats, things are more complex. Cats have
isAdmin: undefined. What happens when we intersect that with theisAdmin: trueinIsAdmin? We might expect{..., isAdmin: never}, or we might expect the entire intersection to reduce tonever. However, something more subtle happens. Here's the example, which type errors with a surprising error message.- Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
const admin: Admin = {kind: 'cat', name: 'Ms. Fluff', isAdmin: true};admin.isAdmin;Result:
type error: Type '"cat"' is not assignable to type '"user"'.
Check the error message above. What does
'user'have to do with any of this? The object is a cat, not a user! This is where type algebra comes in handy. Sometimes the compiler can't tell us exactly why something is wrong, so we have to work the types out for ourselves.We start with the type
IsAdmin & (User | Cat). It's easier to think about this type if we distribute the intersection over the union. Distributing gives us the equivalent type(IsAdmin & User) | (IsAdmin & Cat). Now we can consider those two cases separately.Let's take the right side of the union first:
IsAdmin & Cat. The intersected types haveisAdmin: trueandisAdmin: undefined. Those types don't overlap at all!We might expect the
isAdminproperty to benever, giving us the overall type{kind: 'cat', name: 'Ms. Fluff', isAdmin: never}. However, TypeScript has some specific type handling for this situation, so that's not exactly what happens. The next example is extremely contrived, but it forces TypeScript to give us a specific error message that shows us what the type system does in this case.>
type Cat = {kind: 'cat', name: string, isAdmin: undefined};type IsAdmin = {isAdmin: true};type CatAdmin = IsAdmin & Cat;const catAdmin: CatAdmin = {};Result:
type error: Type '{}' is not assignable to type 'never'. The intersection 'CatAdmin' was reduced to 'never' because property 'isAdmin' has conflicting types in some constituents.That error message is surprisingly clear! It tells us that the entire
IsAdmin & Catintersection was reduced tonever.Now we can return to our type expression,
(IsAdmin & User) | (IsAdmin & Cat). We just saw that the entireIsAdmin & Catintersection isnever. And, as we saw in an earlier lesson,SomeType | neveris justSomeType. We can drop the entire right half of the union, reducing the overall type toIsAdmin & User.We already saw that
IsAdmin & UserhasisAdmin: true. In fact, the full type is{kind: 'user', name: string, isAdmin: true}. That's also the overallAdmintype!There are many ways to think about that result. One view is that TypeScript has helped us: the
Catpart of our type didn't make sense, so it reduced toneverand TypeScript discarded it from the union.On the other hand, we could view this as a shortcoming of TypeScript. The mere fact that we tried to intersect
Admin & Catprobably indicates a mistake on our part. Part of our type didn't make sense, and we'd rather have a type error that shows us the problem. But we got no error; TypeScript allowed theAdmin & Catpart of the type.Both of those explanations are reasonable, even though they conflict. The type behavior that we saw here is necessary to make certain types in TypeScript possible. But we can also make mistakes with it, as with any language feature. TypeScript can't statically detect every conceptual error that we make.
Now we can return to the surprising type error message that we saw when considering the cat side of the type. Here's the code again:
>
type User = {kind: 'user', name: string, isAdmin: boolean};type Cat = {kind: 'cat', name: string, isAdmin: undefined};type IsAdmin = {isAdmin: true};type Admin = IsAdmin & (User | Cat);const admin: Admin = {kind: 'cat', name: 'Ms. Fluff', isAdmin: true};admin.isAdmin;Result:
type error: Type '"cat"' is not assignable to type '"user"'.
Why does TypeScript think that we're assigning
'cat'to the literal type'user'?We already saw that the entire
Admintype reduces to{kind: 'user', name: string, isAdmin: true}. In the example above, we tried to assign a{kind: 'cat', ...}. From TypeScript's perspective, this is a very simple error! Thekindproperty should be'user', but it's'cat'.Let's double-check that explanation. If it's correct, then we should be able to get an identical error message by replacing the
Admintype with the equivalent version that we got through type algebra:{kind: 'user', name: string, isAdmin: true}.>
type Admin = {kind: 'user', name: string, isAdmin: true};const admin: Admin = {kind: 'cat', name: 'Ms. Fluff', isAdmin: true};admin.isAdmin;Result:
type error: Type '"cat"' is not assignable to type '"user"'.
TypeScript didn't need to look any farther than that; the
isAdminproperty didn't even matter when producing that type error! The compiler stopped immediately, reporting that'cat'isn't assignable to'user'.Sometimes intersections are the only tool for a particularly complex job. In those cases, you may find yourself reasoning through the type algebra to find out what's happening.
An earlier lesson recommended using
extendsinstead of type intersection. This lesson showed another example of why:extendscan save you from confusing errors like the one above!