Narrowing down types of co-dependent, union-based, generic function arguments
I have a simple function, that checks if variable
is contained in an array opts
type IType1 = 'text1' | 'text2';
type IType2 = 'text3' | 'text4' | 'text5';
const foo: IType2 = 'text4';
function oneOf(variable, opts) {
return opts.includes(variable);
}
What I want is to make the opts
and variable
co-dependent, so if I would call the function:
oneOf(foo, ['text3', 'text5']) //=> I would get OK
oneOf(foo, ['text3', 'text2']) //=> I would get a warning here, because `IType2` (type of `foo`) does not contain 'text2'
Approach 1
If I wrote:
function oneOf<T extends IType1 | IType2>(variable: T, opts: T[]): boolean{
return opts.includes(variable);
}
I would get OK
in both cases. TS would simply assume that in the second case T extends "text3" | "text4" | "text2"
, which is not what I want.
Approach 2
If I wrote
function oneOf<T1 extends IType1 | IType2, T2 extends T1>(variable: T1, opts: T2[]): boolean{
return opts.includes(variable);
}
I would get an error:
Argument of type 'T1' is not assignable to parameter of type 'T2'.
'T1' is assignable to the constraint of type 'T2', but 'T2' could be instantiated with a
different subtype of constraint '"text1" | "text2" | "text3" | "text4" | "text5"'.
...
Can this be done at all in TS?
The problem with the constraint T extends IType1 | IType2
is that IType1
and IType2
are themselves unions of string literal types. The compiler will collapse that to just T extends 'text1' | 'text2' | 'text3' | 'text4' | 'text5'
and so the compiler will not naturally group those in terms of IType1
and IType2
. If you pass in a value of type 'text4'
then the compiler will infer that T
is 'text4'
and not IType2
.
So there are different ways to approach this. One is that you can keep that union type in the constraint for the type of variable
, but use a conditional type for the type of the element of opts
:
function oneOf<T extends IType1 | IType2>(
variable: T,
opts: Array<T extends IType1 ? IType1 : IType2>
): boolean {
return (opts as readonly (IType1 | IType2)[]).includes(variable);
}
oneOf('text4', ['text3', 'text5']) // okay
oneOf('text4', ['text3', 'text2']) // error
The conditional type T extends IType1 ? IType1 : IType2
has the effect of widening from T
to either IType1
or IType2
. Note that the implementation of the function needs at least one type assertion since the compiler can't really do much with an unresolved generic type... since it doesn't know what T
is, it can't figure out what T extends IType1 ? IType1 : IType2
is; such a type is essentially opaque to the compiler because the evaluation is deferred. By saying opts
is readonly (IType1 | IType2)[]
we're just saying that whatever opts
is, we will be able to read elements of type IType1
or IType2
from it.
Another way to approach this is to give up on generics and just think of different call signatures for IType1
and IType2
. Traditionally you'd do this with overloads like this:
function oneOf(variable: IType1, opts: IType1[]): boolean;
function oneOf(variable: IType2, opts: IType2[]): boolean;
function oneOf(variable: IType1 | IType2, opts: readonly (IType1 | IType2)[]) {
return opts.includes(variable);
}
oneOf('text4', ['text3', 'text5']); // okay
oneOf('text4', ['text3', 'text2']); // error
And this is fine, but overloads can be a little iffy to work with and they don't scale very well (if you had IType1 | IType2 | IType3 | ... | IType10
it might be annoying to write out the call signatures).
An alternative to overloads when the return type of each call signature is the same (like these are both boolean
) is to have a single call signature which takes a rest parameter whose type is a union of tuples:
function oneOf(...[variable, opts]:
[variable: IType1, opts: IType1[]] |
[variable: IType2, opts: IType2[]]
): boolean {
return (opts as readonly (IType1 | IType2)[]).includes(variable);
}
oneOf('text4', ['text3', 'text5']); // okay
oneOf('text4', ['text3', 'text2']); // error
That actually looks much like an overload from the caller's side. From that you could make a programmatic version which calculates the union of tuples for us:
type ValidArgs<T extends any[]> = {
[I in keyof T]: [variable: T[I], opts: T[I][]]
}[number];
function oneOf(...[variable, opts]: ValidArgs<[IType1, IType2]>): boolean {
return (opts as readonly (IType1 | IType2)[]).includes(variable);
}
oneOf('text4', ['text3', 'text5']); // okay
oneOf('text4', ['text3', 'text2']); // error
This is the same as before; ValidArgs<[IType1, IType2]>
evaluates to [variable: IType1, opts: IType1[]] | [variable: IType2, opts: IType2[]]
. It works by taking the input tuple type [IType1, IType2]
and mapping over it to form a new type like [[variable: IType1, opts: IType1[]], [variable: IType2, opts: IType2[]]]
which we then immediately index into with the number
index to get the union of elements of that tuple; namely [variable: IType1, opts: IType1[]] | [variable: IType2, opts: IType2[]]
.
You can see how ValidArgs
might be a little easier to scale up:
type Test = ValidArgs<[0, 1, 2, 3, 4, 5]>;
// type Test = [variable: 0, opts: 0[]] | [variable: 1, opts: 1[]] | [variable: 2, opts: 2[]] |
// [variable: 3, opts: 3[]] | [variable: 4, opts: 4[]] | [variable: 5, opts: 5[]]
Anyway, all of those versions should work reasonably well from the call side, depending on your use case.
Playground link to code