Overload on the type of this to specialize generics (The Lost Item)

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:

_.sum(
_.map(
_.filter(
_.split('' + Math.PI, ''),
digit => digit !== '.'),
Number)); // result is 80

into a chain:

_.chain('' + Math.PI)
.split('')
.filter(digit => digit !== '.')
.map(Number)
.sum()
.value(); // result is 80

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 and map 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:

_(Math.PI)
.map(val => val);
// ~~~ map method not available

You can start by defining the wrapper interface:

interface Wrapper<T> {
value(): T;
}
declare function _<T>(value: T): Wrapper<T>;

(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:

_(Math.PI).value();
// ----- (method) Wrapper<number>.value(): number

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:

interface Wrapper<T> {
value(): T;
}
interface ArrayWrapper<T> extends Wrapper<T[]> {
map<V>(mapFn: (v: T) => V): ArrayWrapper<V>;
}
declare function _<T>(value: T[]): ArrayWrapper<T>;
declare function _<T>(value: T): Wrapper<T>;

You can verify that this gives the desired completions and errors in your editor:

_(Math.PI)  // typing "." offers "value" as the only completion
_(Math.PI)
.map(val => val);
// ~~~ Property 'map' does not exist on type 'Wrapper<number>'.

_([1, 2, 3]) // typing "." offers "map" and "value" completions
_([1, 2, 3]).map(v => '' + v).value(); // ok, type is string[]

So far so good. Now let's add support for the sum method. You can add this to the ArrayWrapper interface:

interface ArrayWrapper<T> extends Wrapper<T[]> {
sum(): T;
}

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:

interface Wrapper<T> {
value(): T;
}
interface ArrayWrapper<T> extends Wrapper<T[]> {
map<V>(mapFn: (v: T) => V): ArrayWrapper<V>;
}
interface ArrayOfNumbersWrapper extends ArrayWrapper<number> {
sum(): Wrapper<number>;
}
interface ArrayOfStringsWrapper extends ArrayWrapper<string> {
sum(): Wrapper<string>;
}
declare function _(value: string[]): ArrayOfStringsWrapper;
declare function _(value: number[]): ArrayOfNumbersWrapper;
declare function _<T>(value: T[]): ArrayWrapper<T>;
declare function _<T>(value: T): Wrapper<T>;

_([1, 2, 3, 4]).sum().value(); // ok, type is number
_([1, 2, 3]).map(v => v * v).sum();
// ~~~
// Property 'sum' does not exist on type 'ArrayWrapper<number>'

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:

interface ArrayOfNumbersWrapper extends ArrayWrapper<number> {
sum(): Wrapper<number>;
map(mapFn: (v: number) => number): ArrayOfNumbersWrapper;
map<V>(mapFn: (v: number) => V): ArrayWrapper<V>;
}

_([1, 2, 3]).map(v => v * v).sum().value(); // ok, type is number

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:

interface Wrapper<T> {
// Methods available for all types:
value(): T;

// Methods available on arrays:
map<U, V>(this: Wrapper<U[]>, mapFn: (v: U) => V): Wrapper<V[]>;

// Methods available on specific types of arrays:
sum(this: Wrapper<number[]>): Wrapper<number>;
sum(this: Wrapper<string[]>): Wrapper<string>;
}

declare function _<T>(value: T): Wrapper<T>;

_(Math.PI).value(); // type is number
_([1, 2, 3]).map(v => '' + v * v).value(); // type is string[]

_([1, 2, 3]).sum().value(); // type is number
_([1, 2, 3]).map(v => v * v).sum().value(); // type is number
_([1, 2, 3]).map(v => '' + v + v).sum().value(); // type is string
_([1, 2, 3]).map(v => '' + v + v).map(Number).sum().value(); // type is number

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:

_(Math.PI).map(val => val);
// ~~~~~~~ The 'this' context of type 'Wrapper<number>' is not assignable
// to method's 'this' of type 'Wrapper<{}[]>'.
// Type 'number' is not assignable to type '{}[]'.

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!

Like this post? Consider subscribing to my newsletter, the RSS feed, or following me on Twitter.
Effective TypeScript Book Cover

Effective TypeScript shows you not just how to use TypeScript but how to use it well. The book's 62 items help you build mental models of how TypeScript and its ecosystem work, make you aware of pitfalls and traps to avoid, and guide you toward using TypeScript’s many capabilities in the most effective ways possible. Regardless of your level of TypeScript experience, you can learn something from this book.

After reading Effective TypeScript, your relationship with the type system will be the most productive it's ever been! Learn more »