Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Constraining type in Typescript generic to be one of several types

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 
like image 661
bjnsn Avatar asked Mar 12 '18 18:03

bjnsn


People also ask

What are constraints in TypeScript?

Using type parameters in generic constraintsTypeScript allows you to declare a type parameter constrained by another type parameter. The following prop() function accepts an object and a property name. It returns the value of the property.

How do you define a generic type in TypeScript?

Assigning Generic ParametersBy passing in the type with the <number> code, you are explicitly letting TypeScript know that you want the generic type parameter T of the identity function to be of type number . This will enforce the number type as the argument and the return value.

How do I create a generic class in TypeScript?

TypeScript supports generic classes. The generic type parameter is specified in angle brackets after the name of the class. A generic class can have generic fields (member variables) or methods. In the above example, we created a generic class named KeyValuePair with a type variable in the angle brackets <T, U> .

What are the function of generic types in TypeScript?

Generics allow creating 'type variables' which can be used to create classes, functions & type aliases that don't need to explicitly define the types that they use. Generics makes it easier to write reusable code.


1 Answers

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 (edit the following is no longer true as of TS4.4) 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

like image 180
jcalz Avatar answered Sep 19 '22 23:09

jcalz