- 1.4k words
- 8 mins
I’ve just released an open-source library called Doddle. This library includes a lazy primitive. In practice, this is a class that accepts an initializer function, wrapping it and making sure it’s called exactly once.
I wanted to make sure it works perfectly for both sync and async computations, but that’s harder than it seems. To explain the challenge and how I solved it, I’d like to present a simpler problem.
The humble list
Let’s say we have a List<T>, and we want a map method that maps each element of the list to something else. We can define it like this:
declare class List<T> {
map<S>(iteratee: (x: T) => S): List<S>
}
But what if we had a List<Promise<T>>? A user who wants to use our map method asynchronously would need to write something like this:
list.map(async p => {
const v = await p
return projection(v)
})
This can get pretty awkward if it’s chained a lot. What if we instead allow the user to project the awaited value of each Promise, rather than the Promise itself?
At runtime, we can check every element to see if it’s a Promise, and if it is, project it using then. Simple enough.
But what about at compile-time? We would need a method that has a different signature depending on what T happens to be!
Trying to use conditional types
Naturally, the first thing we’d reach for is conditional types. The idea is to phrase both the argument and the return type based on what T actually is:
declare class List<T> {
map<R>(
iteratee: (e: T extends Promise<infer S> ? S : T) => R
): List<T extends Promise<any> ? Promise<R> : R>
}
// You can also use Awaited<T> to the same effect
But something interesting happens when T is actually generic at the point of call, such as in the following code:
function example<T>() {
return new List<T>()
.map(x => x) // no-op
.map(x => x) // no-op
}
Intuitively, we would expect the return type of example<T> to be List<T>, since we just added a few no-ops.
Unfortunately, TypeScript disagrees. Instead, its return type turns out to be:
List<(T extends Promise<any> ? Promise<T extends Promise<infer S> ? S : T> : T extends Promise<infer S> ? S : T) extends Promise<...> ? Promise<...> : (T extends Promise<any> ? Promise<T extends Promise<infer S> ? S : T> : T extends Promise<infer S> ? S : T) extends Promise<...> ? S : T extends Promise<any> ? Promise<T extends Promise<infer S> ? S : T> : T extends Promise<infer S> ? S : T>
Even though it looks like some of these conditionals should simplify, TypeScript doesn’t even try. Instead, it lets them build on themselves until we get this thing.
TypeScript does resolve all these conditionals if we call the method as example<number>. But if we keep the code generic, we’re going to end up with these unreadable types just lying around all over the place.
The only way to deal with these things is to use lots of type assertions and any, which probably won’t pass code review.
Is there a better approach? Turns out, yes! But it involves a clever trick that combines three TypeScript features in a way you might not expect.
The conditional overload
The three features are:
- The
thisannotation. - Overload resolution
- Type parameter inference.
Let’s start by taking a look at each feature separately. We’ll start with the this annotation.
The this annotation
In JavaScript, the this argument is what you call the function on. For example, the code thing.doStuff() calls doStuff with thing as the this argument.
TypeScript takes this idea but goes a bit further – functions can declare a this annotation as part of their argument list, like this:
function example(this: { a: 1 }) {
return this.a
}
TypeScript treats that this parameter just like any other argument. It only gets checked when you actually call the function.
So the following code, for example, fails to compile because the this argument is missing:
example()
We can only call it by passing this explicitly:
example.call({ a: 1 })
It’s a pretty obscure feature – but we can use it to get some interesting results.
Methods with a this annotation
Strangely, a this annotation on a method works the same as on a function – it doesn’t take into account where the method is actually defined.
This means we can define a method on a class that we can't call normally:
declare class Example {
doStuff(this: { a: 1 }): void
}
let a = new Example()
a.doStuff() // ❌ `this` argument not assignable to {a: 1}
But by extending the class, we can make the method callable:
declare class Example2 extends Example {
a: 1
}
let b = new Example2()
b.doStuff() // ✅ works
I like to call these conditional methods, and they’re kind of cool – but they’re also something we need to be careful about. These methods aren’t callable but they’re still visible. That leads to a lot of confusion when users try to call them.
Luckily, the next stage of the pattern solves this problem!
Conditional overloads
The trick is using conditional methods as overloads, while ensuring that some version of the method is callable no matter what this happens to be.
Here is an example:
declare class Example {
doThing(this: { a: 1 }): { a: 1 }
doThing(): {}
}
TypeScript picks the first overload that matches – in other words, order matters here. We need to put the specialized overloads first and the relaxed overloads last.
Now we’re finally close to a solution, but we’re not quite there yet. Let’s take a look at how conditional overloads interact with type parameters!
Rephrasing type parameters
For this part, let’s look at the signature of a push method. We’ll declare it in two different ways:
declare class List<T> {
pushNormal(item: T): void
pushWeird<S>(this: List<S>, item: S): void
}
Although these signatures seem different, they actually end up behaving in exactly the same way – thanks to TypeScript’s type parameter inference.
Type parameter inference works at the point of call, using the types of the value parameters to figure out what the type parameters should be.
Since TypeScript treats this as just another argument, it’s also part of this inference process. It goes like this:
- The value of
thishas the typeList<T> - The
thisannotation on the method isList<S> - The compiler infers .
- The inferred signature becomes
pushWeird(this: List<T>, item: T).
It’s not very useful when used this way, of course. But using this pattern, we can also rephrase what S refers to. Take a look:
declare class List<T> {
map<_T, S>(
this: List<Promise<_T>>,
iteratee: (x: _T) => S
): List<Promise<S>>
}
Based on what we know about the this argument:
thismust be assignable toList<Promise<_T>>for some_T.- Inference figures out what
_Tshould be.
In other words, this approach lets us “look inside” T, shaping the signature of map depending on what it happens to be.
Notice how the declared type parameter T isn't used in the signature. Worse, using it actually breaks type checking! That’s why I recommend shadowing the original T when doing this:
declare class List<T> {
map<T, S>(
this: List<Promise<T>>,
iteratee: (x: T) => S
): List<S>
}
To top it off, we just add a second overload to cover the case when T isn't actually a promise:
declare class List<T> {
map<T, S>(
this: List<Promise<T>>,
iteratee: (e: T) => S
): List<Promise<S>>
map<S>(iteratee: (e: T) => S): List<S>
}
Testing it out
With this version, our code works as expected. It works when we know T is a promise:
new List<number>()
.map(async x => x)
.map(x => x + 1) satisfies List<Promise<number>>
While retaining the return type in case of a no-op:
function example<T>() {
return new List<T>().map(x => x).map(x => x) satisfies List<T>
}
Conclusion
Conditional types can be a bit fragile, especially in generic code.
In this post, we looked at a way of having conditional logic without conditional types, using TypeScript’s overloads feature together with this annotations.
I hope you find it useful!
