Constraining type in Typescript generic to be one of several types

bjnsn picture bjnsn · Mar 12, 2018 · Viewed 12k times · Source

I'm trying to constrain the input of a generic to be one of several types. The closest notation I've found is using union types. Here is a trivial example:

interface IDict<TKey extends string | number, TVal> { 
    // Error! An index signature parameter type must be 
    // a 'string' or a 'number'
    [key: TKey]: TVal; 
}

declare const dictA: IDict<string, Foo>;
declare const dictB: IDict<number, Foo>;

What I'm looking for, in this example, is a way to say that TKey should be either string or number, but not the union of them.

Thoughts?

Note: This is a specific case of a broader question. For example, I have another case where I have a function that accepts text which can be either a string or StructuredText (parsed Markdown), transforms it, and returns exactly the corresponding type (not a subtype).

function formatText<T extends string | StructuredText>(text: T): T {/*...*/}

Technically I could write that as an overload, but that doesn't seem like the correct way.

function formatText(text: string): string;
function formatText(text: StructuredText): StructuredText;
function formatText(text) {/*...*/}

An overload also proves problematic, because it won't accept a union type:

interface StructuredText { tokens: string[] }

function formatText(txt: string): string;
function formatText(txt: StructuredText): StructuredText;
function formatText(text){return text;}

let s: string | StructuredText;
let x = formatText(s); // error

Answer

jcalz picture jcalz · Mar 14, 2018

Updated for TS3.5+ on 2019-06-20

Issue #1: K extends string | number for the index signature parameter:

Yeah, this can't be done in a very satisfying way. There are a few issues. The first is that TypeScript only recognizes two direct index signature types: [k: string], and [k: number]. That's it. You can't do a union of those (no [k: string | number]), or a subtype of those (no [k: 'a'|'b']), or even an alias of those: (no [k: s] where type s = string).

The second issue is that number as an index type is a weird special case that doesn't generalize well to the rest of TypeScript. In JavaScript, all object indices are converted to their string value before being used. That means that a['1'] and a[1] are the same element. So, in some sense, the number type as an index is more like a subtype of string. If you are willing to give up on number literals and convert them to string literals instead, you have an easier time.

If so, you can use mapped types to get the behavior you want. In fact, there is a type called Record<> that's included in the standard library that is exactly what I'd suggest using:

type Record<K extends string, T> = {
    [P in K]: T;
};

type IDict<TKey extends string, TVal> = Record<TKey, TVal>
declare const dictString: IDict<string, Foo>; // works
declare const dictFooBar: IDict<'foo' | 'bar', Foo>; // works
declare const dict012: IDict<'0' | '1' | '2', Foo>; // works
dict012[0]; // okay, number literals work
dict012[3]; // error
declare const dict0Foo: IDict<'0' | 'foo',Foo>; // works

Pretty close to working. But:

declare const dictNumber: IDict<number, Foo>; // nope, sorry

The missing piece getting number to work would be a type like numericString defined like

type numericString = '0'|'1'|'2'|'3'|'4'|'5'|'6'|'7' // ... etc etc

and then you could use IDict<numericString, Foo> which would behave like you want IDict<number, Foo> to. Without a type like that, there's not much point trying to force TypeScript to do this. I'd recommend giving up, unless you have a very compelling use case.

Issue #2: Generics that can be widened to a type from a list:

I think I understand what you want here. The idea is that you'd like a function that takes an argument of a type that extends a union like string | number, but it should return a type which is widened to one or more of the elements of that union. You're trying to avoid an issue with subtypes. So, if the argument is 1, you don't want to commit to outputting a 1, just a number.

Before now, I'd say just use overloads:

function zop(t: string): string; // string case
function zop(t: number): number; // number case
function zop(t: string | number): string | number; // union case
function zop(t: string | number): string | number { // impl
   return (typeof t === 'string') ? (t + "!") : (t - 2);
}

This behaves how you'd like:

const zopNumber = zop(1); // return type is number
const zopString = zop('a'); // return type is string 
const zopNumberOrString = zop(
  Math.random()<0.5 ? 1 : 'a'); // return type is string | number

And that's the suggestion I'd give if you just have two types in your union. But that could get unwieldy for larger unions (e.g., string | number | boolean | StructuredText | RegExp), since you need to include one overload signature for every nonempty subset of elements from the union.

Instead of overloads we can use conditional types:

// OneOf<T, V> is the main event:
// take a type T and a tuple type V, and return the type of
// T widened to relevant element(s) of V:
type OneOf<
  T,
  V extends any[],
  NK extends keyof V = Exclude<keyof V, keyof any[]>
> = { [K in NK]: T extends V[K] ? V[K] : never }[NK];

Here is how it works:

declare const str: OneOf<"hey", [string, number, boolean]>; // string
declare const boo: OneOf<false, [string, number, boolean]>; // boolean
declare const two: OneOf<1 | true, [string, number, boolean]>; // number | boolean

And here's how you can declare your function:

function zop<T extends string | number>(t: T): OneOf<T, [string, number]>;
function zop(t: string | number): string | number { // impl
   return (typeof t === 'string') ? (t + "!") : (t - 2);
}

And it behaves the same as before:

const zopNumber = zop(1); // 1 -> number
const zopString = zop('a'); // 'a' -> string
const zopNumberOrString = zop(
  Math.random()<0.5 ? 1 : 'a'); // 1 | 'a' -> string | number

Whew. Hope that helps; good luck!

Link to code