Effective TypeScript is nearly 400 pages long, but I've received the most feedback by far on just one passage. It comes in Item 7: Think of Types as Sets of Values:
keyof (A&B) = (keyof A) | (keyof B)
keyof (A|B) = (keyof A) & (keyof B)If you can build an intuition for why these equations hold, you'll have come a long way toward understanding TypeScript's type system!
I'll explain these equations in a moment. But before I do, head over to the TypeScript Playground and test them out with a few types. See if you can build that intuition for why they hold.
I first saw these equations in Anders Hejlsberg's keynote at TSConf 2018 ("Higher order type equivalences" at 26m15s):
Anders' explanation at the talk was helpful, but I still had to stare at them for a long time before they clicked. But when they did, I felt like I'd had a real insight about how TypeScript types work.
The feedback on these equations in the book is typically that I need to explain them more. Some readers have even claimed they're wrong. (They're not!) By presenting them a bit cryptically, I wanted to give readers a chance to think through them and have an insight of their own.
With that out of the way, let's dig into why these equations hold, and why they're interesting.
We can start by plugging in concrete types for A
and B
:
|
What's NamedPoint & Point3D
, the intersection of these two types? It's easy to think that it's an interface
with just the common fields:
|
That's not what it is, though. To understand the intersection of these types, we need to think a little more about what values are assignable to each type. A NamedPoint
is an object with three properties, name
, x
, and y
, with the expected types:
|
But a NamedPoint
could have other properties, too. In particular it could have a z
property:
|
(We have to go through an intermediate object to avoid excess property checking errors here. If you have a copy of Effective TypeScript, check out Item 11: Distinguish Excess Property Checking from Type Checking.)
There's nothing special about z
. It could have other properties, too, and still be assignable to NamedPoint
. For this reason, we sometimes say that TypeScript types are "open."
Of course, Point3D
is open, too. It could also have other fields, including a name
field:
|
So namedXYZ
is assignable to both NamedPoint
and Point3D
. And that is the very definition of an intersection. Sure enough, namedXYZ
is assignable to the intersection of these types, too:
|
This gives us a hint about what the intersection looks like:
|
This type is also "open:" a NamedPoint3D
might have more than these four fields. But it has to have at least these four.
To intersect these two types, we unioned their properties. We can see this in code using keyof
:
|
So keyof (A&B) = (keyof A) | (keyof B)
!
(The weird {} &
forces TypeScript to print out the results of keyof
. I wish this weren't necessary.)
What about the other relationship, keyof (A|B)
? keyof T
will only include a property if TypeScript can be sure that it will be present on values assignable to T
(with a caveat, see below).
Again, let's make this more concrete with some examples:
|
To be assignable to A|B
, a value must be assignable to either A
or B
(or both!). So these values are both assignable to NamedPoint | Point3D
:
|
Thinking about keyof
, which properties belong to both those objects? It's just "x"
and "y"
. And that's keyof
for the union type:
|
So keyof (A|B) = (keyof A) & (keyof B)
and the equation holds.
Hopefully working through these examples with some concrete types makes the equations clearer. I really like them because they're concise but still manage to say a lot about how types work in TypeScript.
I mentioned one caveat, and it has to do with optional fields:
|
justX
is assignable to PartialPoint
, but it doesn't have a y
property, which you'd expect given the keyof
.
Optional fields are a little strange when you think about types in a set-theoretic way. On the one hand, it's surprising that keyof PartialPoint
includes "y"
because values needn't have that property. On the other hand, it would be incredibly annoying if it didn't because keyof
is so often used with mapped types, and you'd really like to map over all the fields, not just the required ones.
At the end of the day, what's the difference between these two types?
|
I'll cryptically say "not much!" and leave it at that!