- 700 words
- 4 mins
I recently wrote the
Let’s say we have a type called List<T> that looks like this:
interface List<T> {
at(n: number): T;
}
We want to define a constructor function list that creates an instance of List<T> from any number of elements. We want the List<T> type to work like an array, so the code list(x, y, z) should always compile.
This seems like a classic use case for a rest argument. Something like this:
declare function list<T>(...args: T[]): List<T>
But there's a problem here. While this approach does work if we just use values like 1 and 2:
const example: List<number> = list(1, 2)
It fails if we add in something else, like a string:
const example = list(1, "hello world")
// ⛔ Argument of type `string` is not assignable to parameter of type `number`
We get a different version of this problem if we instead pass unrelated object types:
list({ a: 1 }, { b: 2 })
This does compile. But where we might expect to get a neat disjunction type, the compiler widens it into something pretty weird:
List<{ a: 1; b?: undefined } | { b: 2; a?: undefined }>
Why is this happening, and is there a way to fix it?
When type inference fails
Because we’re not specifying the type parameter T, we’re asking TypeScript to figure out what it should be on its own. In other words, we’re asking the compiler to infer it using the types it does know about.
Type inference is a convenience feature that can work in all kinds of ways. Some of these methods always succeed – like always inferring unknown or a disjunction of all the inputs. But others can lead to compilation errors, and that’s what happened here.
In fact, I’m not exactly sure how inference works in this case. Here’s what I’ve learned:
- It widens types, but only up to a point.
- Literal types, objects, and primitive types are all treated differently.
- The order in which the parameters appear can matter.
- But in other cases, TypeScript ignores it.
Here are some tests – if you manage to piece it together, I’d love to hear about it in the Discord!
I understand the motivation, though – TypeScript is trying to avoid inferring a T that’s “too broad”. It’s a general solution that probably works for most APIs. That means it fails for some, and our array-like List constructor is just one example.
Where tuples come in
Since defining an array always works, TypeScript uses different logic for inferring its type – it just takes the disjunction of all the element types. Since we want to reproduce this logic, we’d like TypeScript to do the same with List.
const example = [ 1, "2", true ] satisfies (1 | "2" | true)[]
We can make use of this logic ourselves by rephrasing the signature using a generic tuple argument, which we’ll use to annotate the rest parameter:
declare function List<Ts extends readonly any[]>(...items: Ts): List<Ts[number]>
This isn’t a totally different signature – we’re just being a bit more specific about how we want inference to happen.
Defined like this, the following code compiles just fine, giving us a clean disjunction of value and object types:
List(1, "hello world", { a: 1 }) satisfies List<number | string | { a: number }>
Conclusion
Using generics with rest parameters can sometimes cause type inference to fail. In this post, we’ve looked at a way of overcoming this problem using tuples – leading to more flexible variadic signatures.
You should use this approach whenever you want to allow rest arguments to accept any combination of types, including some that seem incompatible with each other.
