Vladimir Klepov as a Coder

Making sense of TypeScript using set theory

I've been working with TypeScript for a long long time. I think I'm not too bad at it. However, to my despair, some low-level behaviors still confuse me:

  • Why does 0 | 1 extends 0 ? true : false evaluate to false?
  • I'm very ashamed, but I sometimes confuse "subtype" and "supertype". Which is which?
  • While we're at it, what are type "narrowing" and "widening", and how do they relate to sub/supertypes?
  • If you want an object that satisfies both { name: string } and { age: number }, do you & or |? Both make some sense, since I want a union of the functionality in both interfaces, but I also want the object to satisfy left & (and) right interfaces.
  • How is any different from unknown? All I get is imprecise mnemonics like "Avoid Any, Use Unknown". Why?
  • What, exactly, is never? "A value that never happens" is very dramatic, but not too precise.
  • Why whatever | never === whatever and whatever & never === never?
  • Why on earth is const x: {} = true; valid TS code? true is clearly not an empty object.

I was doing some research on never, and stumbled upon Zhenghao He's Complete Guide To TypeScript’s Never Type (check out his blog, it's super cool!). It mentions that a type is just a set of values, and — boom — it clicked. I went back to the basics, re-formulating everything I know about TS into set-theoretic terms. Follow me as I:

  • Refresh my knowledge of set theory,
  • Map TS concepts to their set counterparts,
  • Start simple with booelan, null and undefined types,
  • Extend to strings and numbers, finding some types that TS can not express,
  • Jump into objects, proving my assumptions about them wrong,
  • Finally gain confidence writing extends caluses,
  • And put unknown and any where they belong.

In the end, I solve most of my questions, grow much cozier with TS, and come up with this brilliant map of TS types:

Set theory

First up, a refresher on set theory. Feel free to skip if you're a pro, but my algebra skills are a bit rusty, so I could use a reminder of how it works.

Sets are unordered collections of objects. In kindergarten terms: say we have two apples aka objects (let's call them ivan and bob, shall we?), and some bags aka sets where we can put the apples. We can make, in total, 4 apple sets:

  1. A bag with apple ivan, { ivan } — sets are written as curly brackets with the set items inside.
  2. Similarly, you can have a bag with apple bob, { bob }.
  3. A bag with both apples, { ivan, bob }. Hold onto your hats, this is called a universe because at the moment there's nothing in our world except these two apples.
  4. An empty bag aka empty set, {}. This one gets a special symbol, ∅

Sets are often drawn as "venn diagrams", with each set represented as a circle:

Apart from listing all the items, we can also build sets by condition. I can say "R is a set of red apples" to mean { ivan }, considernig ivan is red and bob is green. So far, so good.

Set A is a subset of set B if every element from A is also in B. In our apple world, { ivan } is a subset of { ivan, bob }, but { bob } is not a subset of { ivan }. Obviously, any set is a subset of itself, and {} is a subset of any other set S, because not a single item from {} is missing from S.

There are a few useful operators defined on sets:

  • Union C = A ∪ B contains all the elements that are in A or in B. Note that A ∪ ∅ = A
  • Intersection C = A ∩ B contains all the elements that are in A and B. Note that A ∩ ∅ = ∅
  • Difference C = A \ B contains all the elements that are in A, but not in B. Note that A \ ∅ = A

This should be enough! Let's see how it all maps to types.

What does it have to do with types

So, the big reveal: you can think of "types" as sets of JavaScript values. Then:

  1. Our universe is all the values a JS program can produce.
  2. A type (not even a typescript type, just a type in general) is some set of JS values.
  3. Some types can be represented in TS, while other can not — for example, "non-zero numbers".
  4. A extends B as seen in conditional types and generic constraints can be read as "A is subset of B".
  5. Type union, |, and intersection, &, operators are just the union and intersection of two sets.
  6. Exclude<A, B> is as close as TS gets to a difference operator, except it only works when both A and B are union types.
  7. never is an empty set. Proof: A & never = never and A | never = A for any type A, and Exclude<0, 0> = never.

This change of view already yields some useful insights:

  • Subtype of type A is a subset of type A. Supertype is a superset. Easy.
  • Widening makes a type-set wider by allowing some extra values. Narrowing removes certain values. Makes geometrical sense.

I know this all sounds like a lot, so let's proceed by example, starting with a simple case of boolean values.

Boolean types

For now, pretend JS only has boolean values. There are exaclty twotrue and false. Recalling the apples, we can make a total of 4 types:

  • Literal types true and false, each made up of a single value;
  • boolean, which is any boolean value;
  • The empty set, never.

The diagram of the "boolean types" is basically the one that we had for apples, just the names swapped:

Let's try moving between type world and set world:

  • boolean can be written as true | false (in fact, that's exactly how TS impements it).
  • true is a subset (aka sub-type) of boolean
  • never is an empty set, so never is a sub-set/type of true, false, and boolean
  • & is an intersection, so false & true = never, and boolean & true = (true | false) & true = true (the universe, boolean, doesn't affect intersections), and true & never = never, etc.
  • | is a union, so true | never = true, and boolean | true = boolean (the universe, boolean, "swallows" other intersection items because they're all subsets of universe).
  • Exclude correctly computes set difference: Exclude<boolean, true> -> false

Now, a little self-assessment of the tricky extends cases:

type A = boolean extends never ? 1 : 0;
type B = true extends boolean ? 1 : 0;
type C = never extends false ? 1 : 0;
type D = never extends never ? 1 : 0;

If you recall that "extends" can be read as "is subset of", the answer should be clear — A0,B1,C1,C1. We're making progress!

null and undefined are just like boolean, except they only contain one value each. never extends null still holds, null & boolean is never since no JS value can simultaneously be of 2 different JS types, and so on. Let's add these to our "trivial types map":

Strings and other primitives

With the simple ones out of the way, let's move on to string types. At first, it seems that nothing's changed — string is a type for "all JS strings", and every string has a corresponding literal type: const str: 'hi' = 'hi'; However, there's one key difference — there are infinitely many possible string values.

It might be a lie, because you can only represent so many strings in finite computer memory, but a) it's enough strings to make enumerating them all unpractical, and b) type systems can operate on pure abstrations without worrying about dirty real-life limitations.

Just like sets, string types can be constructed in a few different ways:

  • | union lets you constuct any finite string set — e.g. type Country = 'de' | 'us';. This won't work for infinite sets — say, all strings with length > 2 — since you can't write an infinite list of value.
  • Funky template literal types let you construct some infinite sets — e.g. type V = `v${string}`; is a set of all strings that start with v.

We can go a bit further by making unions and intersections of literal and template types. Fun time: when combining a union with a template, TS is smart enough to just filter the literals againts the template, so that 'a' | 'b' & `a${string}` = 'a'. Yet, TS is not smart enough to merge templates, so you get really fancy ways of saying never, such as `a${string}` & `b${string}` (obviously, a string can't start with "a" and "b" at the same time).

However, some string types are not representable in TS at all. Try "every string except 'a'". You could Exclude<string, 'a'>, but since TS doesn't actually model string as union of all possible string literals, this in fact evaluates back to string. The template grammar can not express this negative condition either. Bad luck!

The types for numbers, symbols and bigints work the same way, except they don't even get a "template" type, and are limited to finite sets. It's a pity, as I could really use some number subtypes — integer, number between 0 and 1, or positive number. Anyways, all together:

Phew, we've covered all primitive, non-intersecting JS / TS types. We've gotten comfortable moving between sets and types, and discovered that some types can't be defined in TS. Here comes the tricky part.

Interfaces & object types

If you think const x: {} = 9; makes no sense, this section is for you. As it appears, our mental model of what TS object types / records / interfaces was built on the wrong assumptions.

First, you'd probably expect types like type Sum9 = { sum: 9 } to act like "literal types" for objects — matching a single object value { sum: 9 }, adjusted for referential equality. This is absolutely not how it works. Instead, Sum9 is a "thing on which you can access propery sum to get 9" — more like a condition / constraint. This lets us call (data: Sum9) => number with an object obj = { sum: 9, date: '2022-09-13' } without TS complaining about unknown date property. See, handy!

Then, {} type is not an "empty object" type corresponding to a {} JS literal, but a "thing where I can access properties, but I don't care about any particular properties". Aha, now we can see what's going on in our initial mind-bender: if x = 9, you can safely x['whatever'], so it satisfies the unconstrained {} interface. In fact, we can even make bolder claims like const x: { toString(): string } = 9;, since we can x.toString() and actuallty get a string. More yet, keyof number gives us "toString" | "toFixed" | "toExponential" | "toPrecision" | "valueOf" | "toLocaleString", meaning that TS secretly sees our primitive type as an object, which it is (thanks to autoboxing). null and undefined do not satisfy {}, because they throw if you try to read a property. Not super intuitive, but makes sense now.

Coming back to my little "| or &" problem, & and | operate on "value sets", not on "object shapes", so you need { name: string } & { age: number } to get objects with both name and (extra hint: and = &) age.

Oh, and what about that odd object type? Since every property on an interface just adds a constraint to the "thing" we're typing, there's no way to declare an interface that filters out primitive values. It's why TS has a built-in object type that means specifically "JS object, not a primitive". Yes, you can intersect with object to get only non-primitive values satisfying an interface: const x: object & { toString(): string } = 9 fails.

Let's add all of these to our type map:

extends

extends keyword in TS can be confusing. It comes from the object-oriented world where you extend a class in the sense of adding functionality to it, but, since TS uses structural typing, extends as used in type Extends<A, B> = A extends B ? true : false is not the same extends from class X extends Y {}.

Instead, A extends B can be read as A is a sub-type of B or, in set terms, A is a subset of B. If B is a union, every member of A must also be in B. If B is a "constrained" interface, A must not violate any of B's constraints. Good news: a usual OOP class A extends B {} fits A extends B ? 1 : 0. So does 'a' extends string, meaning that (excuse the pun) TS extends extends extends.

This "subset" view is the best way to never mix up the order of extends operands:

  • 0 | 1 extends 0 is false, since a 2-element set {0, 1} is not a subset of the 1-element {0} (even though {0,1} does extend {1} in a geometrical sense).
  • never extends T is always true, because never, the empty set, is a subset of any set.
  • T extends never is only true if T is never, because an empty set has no subsets except itself.
  • T extends string allows T to be a string, a literal, or a literal union, or a template, because all of these are subsets of string.
  • T extends string ? string extends T makes sure that T is exactly string, because that's the only way it can be both a subset and a superset of string.

unknown and any

Typescript has two types that can represent an arbitrary JS value — unknown and any. The normal one is unknown — the universe of JS values:

// It's a 1
type Y = string | number | boolean | object | bigint | symbol | null | undefined extends unknown ? 1 : 0;
// a shorter one, given the {} oddity
type Y2 = {} | null | undefined extends unknown ? 1 : 0;
// For other types, this is 0:
type N = unknown extends string ? 1 : 0;

On a puzzling side, though:

  1. unknown is not a union of all other base types, so you can't Exclude<unknown, string>
  2. unknown extends string | number | boolean | object | bigint | symbol | null | undefined is false, meaning that some TS types are not listed. I suspect enums.

All in all, it's safe to think of unknown as "the set of all possible JS values".

any is the weird one:

  • any extends string ? 1 : 0 evaluates to 0 | 1 which is basically a "dunno".
  • Even any extends never ? 1 : 0 evaluates to 0 | 1, meaning that any might be empty.

We should conclude that any is "some set, but we're not sure which one" — like a type NaN. However, upon further inspection, string extends any, unknown extends any and even any extends any are all true, none of which holds for "some set". So, any is a paradox — every set is a subset of any, but any might be empty. The only good news I have is that any extends unknown, so unknown is still the universe, and any does not allow "alien" values.

So, to finish mapping our types, we wrap our entire diagram into unknown bubble:


Today, we've learnt to that TS types are basically sets of JS values. Here's a little dictionary to go from type-world to set-world, and back:

  • Our universe = all JS values = the type unknown
  • never is an empty set.
  • Subtype = narrowed type = subset, supertype = widened type = superset.
  • A extends B can be read as "A is subset of B".
  • Union and intersection types are, really, just set union and intersection.
  • Exclude is an approximation of set difference that only works on union types.

Going back my our initial questions:

  • 0 | 1 extends 0 is false because {0,1} is not a subset of {0}
  • & and | work on sets, not on object shapes. A & B is a set of things that satisfy both A and B.
  • unknown is the set of all JS values. any is a paradoxical set that includes everything, but might also be empty.
  • Intersecting with never gives you never because it's an empty set. never has no effect in a union.
  • const x: {} = true; works because TS interfaces work by constraining the property values, and we haven't constrained anything here, so true fits.

We still have a lot of TS mysteries to solve, so stay tuned!

More? All articles ever
Written in by your friend, Vladimir. Follow me on Twitter to get post updates. I have RSS, too. And you can buy me a coffee!
Older
Seven habits of bad interviewers
Newer
The complete guide to safe type narrowing in TypeScript