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:
splitmethod should only be available on wrapped strings.
mapmethods should only be available on arrays. (In the real lodash library they work on objects, too, but have different type signatures.)
summethod 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
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
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
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!
- Specify a narrower type for
thisto specialize generic methods in interfaces.
- Avoid building elaborate series of wrapper types. TypeScript is better at this!
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!