web-dev-qa-db-fra.com

Contraindre le type dans le générique TypeScript à l'un des nombreux types

J'essaie de contraindre l'entrée d'un générique à l'un des nombreux types. La notation la plus proche que j'ai trouvée utilise des types d'union. Voici un exemple trivial:

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>;

Ce que je recherche, dans cet exemple, est une façon de dire que TKey doit être soit string ou number, mais pas leur union.

Pensées?

Remarque: Il s'agit d'un cas spécifique d'une question plus large. Par exemple, j'ai un autre cas où j'ai une fonction qui accepte text qui peut être soit un string ou StructuredText (analyse Markdown), la transforme et renvoie exactement le type correspondant (pas un sous-type).

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

Techniquement, je pourrais écrire cela comme une surcharge, mais cela ne semble pas être la bonne façon.

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

Une surcharge s'avère également problématique, car elle n'accepte pas un type d'union:

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
16
bjnsn

Mis à jour pour TS3.5 + le 2019-06-20

Problème n ° 1: K extends string | number pour le paramètre de signature d'index:

Oui, cela ne peut pas être fait de manière très satisfaisante. Il y a quelques problèmes. La première est que TypeScript ne reconnaît que deux types de signature d'index direct: [k: string], et [k: number]. C'est tout. Vous ne pouvez pas faire l'union de ceux-ci (pas de [k: string | number]), ou un sous-type de ceux-ci (pas de [k: 'a'|'b']), ou même un alias de ceux-ci: (pas de [k: s]type s = string).

Le deuxième problème est que number en tant que type d'index est un cas spécial étrange qui ne se généralise pas bien au reste de TypeScript. En JavaScript, tous les index d'objets sont convertis en leur valeur de chaîne avant d'être utilisés. Cela veut dire que a['1'] et a[1] sont le même élément. Donc, dans un certain sens, le type number en tant qu'index ressemble plus à un sous-type de string. Si vous êtes prêt à abandonner les littéraux number et à les convertir à la place en littéraux string, vous aurez plus de facilité.

Si c'est le cas, vous pouvez utiliser types mappés pour obtenir le comportement souhaité. En fait, il existe un type appelé Record<> c'est inclus dans la bibliothèque standard c'est exactement ce que je suggère d'utiliser:

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

Assez proche du travail. Mais:

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

La pièce manquante permettant à number de fonctionner serait un type comme numericString défini comme

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

puis vous pouvez utiliser IDict<numericString, Foo> qui se comporterait comme vous le souhaitez IDict<number, Foo> à. Sans un type comme celui-là, il ne sert à rien d'essayer de forcer TypeScript à le faire. Je recommanderais d'abandonner, sauf si vous avez un cas d'utilisation très convaincant.

Problème n ° 2: génériques pouvant être étendus à un type à partir d'une liste:

Je pense que je comprends ce que vous voulez ici. L'idée est que vous souhaitez une fonction qui prend un argument d'un type qui étend une union comme string | number, mais il doit renvoyer un type qui est élargi à un ou plusieurs des éléments de cette union. Vous essayez d'éviter un problème avec les sous-types. Donc, si l'argument est 1, vous ne voulez pas vous engager à sortir un 1, juste un number.

Avant maintenant, je dirais simplement utiliser des surcharges:

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);
}

Cela se comporte comme vous le souhaitez:

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

Et c'est la suggestion que je ferais si vous n'avez que deux types dans votre syndicat. Mais cela pourrait devenir compliqué pour les grands syndicats (par exemple, string | number | boolean | StructuredText | RegExp), car vous devez inclure une signature de surcharge pour chaque sous-ensemble d'éléments non vide de l'union.

Au lieu de surcharges, nous pouvons utiliser types conditionnels :

// 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];

Voici comment cela fonctionne:

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

Et voici comment déclarer votre fonction:

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);
}

Et il se comporte comme avant:

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

Ouf. J'espère que cela pourra aider; bonne chance!

Lien vers le code

12
jcalz