Execute Program

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 x in x * (y + z) distributes over y + z. We can rewrite it as (x * y) + (x * z).

  • >
    const [x, y, z] = [2, 3, 4];
    x * (y + z);
    Result:
    14Pass Icon
  • >
    const [x, y, z] = [2, 3, 4];
    (x * y) + (x * z);
    Result:
    14Pass Icon
  • >
    const [x, y, z] = [3, 4, 5];
    x * (y + z) === (x * y) + (x * z);
    Result:
    truePass Icon
  • However, if we change the expression to x + (y * z), then x doesn'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:
    14Pass Icon
  • >
    const [x, y, z] = [2, 3, 4];
    (x + y) * (x + z);
    Result:
    30Pass Icon
  • >
    const [x, y, z] = [3, 4, 5];
    x + (y * z) === (x + y) * (x + z);
    Result:
    falsePass Icon
  • Boolean operators are also distributive. For example, the && operator distributes like *. We can distribute x over the || terms in x && (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 the A over the B | 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: boolean and cats have isAdmin: undefined. Finally, we have an IsAdmin object type that only specifies isAdmin: true.

  • >
    type User = {kind: 'user', name: string, isAdmin: boolean};
    type Cat = {kind: 'cat', name: string, isAdmin: undefined};
    type IsAdmin = {isAdmin: true};
  • We'll intersect IsAdmin with 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 have isAdmin: 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:
    truePass Icon
  • 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'.Pass Icon
  • Why does this type require isAdmin: true? It's because User has isAdmin: boolean, but IsAdmin has isAdmin: true. When we intersect the two types, we get isAdmin: boolean & true. Intersecting boolean & true is equivalent to just true, because true is a kind of boolean. The result is that users must have isAdmin: true.

  • For cats, things are more complex. Cats have isAdmin: undefined. What happens when we intersect that with the isAdmin: true in IsAdmin? We might expect {..., isAdmin: never}, or we might expect the entire intersection to reduce to never. 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"'.Pass Icon
  • 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 have isAdmin: true and isAdmin: undefined. Those types don't overlap at all!

  • We might expect the isAdmin property to be never, 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.Pass Icon
  • That error message is surprisingly clear! It tells us that the entire IsAdmin & Cat intersection was reduced to never.

  • Now we can return to our type expression, (IsAdmin & User) | (IsAdmin & Cat). We just saw that the entire IsAdmin & Cat intersection is never. And, as we saw in an earlier lesson, SomeType | never is just SomeType. We can drop the entire right half of the union, reducing the overall type to IsAdmin & User.

  • We already saw that IsAdmin & User has isAdmin: true. In fact, the full type is {kind: 'user', name: string, isAdmin: true}. That's also the overall Admin type!

  • There are many ways to think about that result. One view is that TypeScript has helped us: the Cat part of our type didn't make sense, so it reduced to never and 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 & Cat probably 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 the Admin & Cat part 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"'.Pass Icon
  • Why does TypeScript think that we're assigning 'cat' to the literal type 'user'?

  • We already saw that the entire Admin type 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! The kind property 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 Admin type 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"'.Pass Icon
  • TypeScript didn't need to look any farther than that; the isAdmin property 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 extends instead of type intersection. This lesson showed another example of why: extends can save you from confusing errors like the one above!