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