I cut one item from Effective TypeScript during the final stages of editing. Four years later, it's time for it to see the light of day! It's a trick for specializing generic types for certain subtypes of their type parameters. This post shows how it works, why it's indispensible for wrapper types, and also explains why I cut it from the book.
As you write type declarations for generic classes, you may find that you want to make some methods available only for particular values of the generic parameter. This often comes up with wrapper objects. In the lodash utility library, for example, you can rewrite a series of function calls:
|
into a chain:
|
The call to _.chain(val)
creates a wrapper object which is eventually unwrapped by a call to .value()
. This reads more naturally since the execution order matches the code order: top to bottom, left to right.
Modeling this in TypeScript presents some challenges:
- The
split
method should only be available on wrapped strings. - The
filter
andmap
methods should only be available on arrays. (In the real lodash library they work on objects, too, but have different type signatures.) - The
sum
method should only be available on wrapped arrays of strings or numbers.
For example, calling map
on a wrapped number should be an error:
|
You can start by defining the wrapper interface:
|
(Since we're writing declarations here, we assume there's already an implementation defined elsewhere which may use different classes at runtime.)
You can verify that this wraps and unwraps values by writing a simple chain and inspecting the intermediate types in your editor:
|
As expected, this forms a Wrapper<number>
and then unwraps it.
So what if you want to add a map
method that's only available on arrays? If you add it directly to the Wrapped
interface, it will be available on all wrapped objects, not just arrays. Perhaps a better approach is to create a specialized ArrayWrapper
interface:
|
You can verify that this gives the desired completions and errors in your editor:
|
So far so good. Now let's add support for the sum
method. You can add this to the ArrayWrapper
interface:
|
and this will work, but it will also let you sum an array of Date
objects to get a single Date
, or an array of regular expressions to get a single regular expression. These should be errors.
You could try to model this out explicitly by creating more specialized interfaces:
|
What went wrong? When you use map
on an ArrayOfNumbersWrapper
, the result reverts back to ArrayWrapper<number>
, which doesn't have a sum
method. You can patch this:
|
But this is a losing battle. There's always going to be some combination of method calls that your series of interfaces misses. This will be a frustrating experience for your users, since a TypeScript error will be only loosely correlated with a runtime error.
Taking a step back, this tracking of types through function calls is exactly what the TypeScript compiler does and is good at. It would be better to let it figure out that the wrapped type is number[]
and provide the sum
method in that case, rather than having to think of every possible way you could get a wrapped number array.
The trick to doing this is to specialize methods on the type of this
:
|
Everything works! The type checker is indeed quite good at tracking types: even trickier cases we didn't cover before, like mapping from strings to numbers and back, work as expected. What's more, this code is significantly clearer than our previous attempts. There's only a single Wrapper
interface. As you add more and more specialized methods, the returns on this simplicity compound.
The only downside is that the error you get from calling an unavailable method is a bit cryptic:
|
But at least there's an error. Hopefully there are enough details in it to make the user realize that the map
method only applies to arrays.
If you ever find yourself building a complex series of interfaces to model behaviors in a type declarations file, ask whether you could model the same thing by specializing on a generic type parameter. Overloading on the type of this
will let TypeScript do the hard work for you. It'll be more accurate and a whole lot simpler!
Things to Remember
- Specify a narrower type for
this
to specialize generic methods in interfaces. - Avoid building elaborate series of wrapper types. TypeScript is better at this!
Why was this cut from the book?
This item was inspired by Daniel Rossenwasser's comment on my 2017 blog post A typed chain: exploring the limits of TypeScript. The technique is perfect for typing _.chain
and other generic wrappers. So why did I cut it? It's a complex technique to motivate, and I struggled to think of any other situation where it would be useful. Complex and not that useful? Sounded like a good one to drop!
I don't think the technique of specializing on this
is widely-known, so perhaps this blog post can inspire some creative new use cases! Have you every run across this trick? Do you have a use case? Let me know in the comments!