Romain Durand

🎅 Advent of TypeScript 2024 - Jour 11

Le 11 décembre 2024 à 12:34

Excuses, Excuses - Des types instanciables ?

https://www.adventofts.com/events/2024/11

Aujourd'hui j'ai l'impression de m'être égaré, les solutions que j'ai trouvé me semblent bien trop complexes au regard de la progression de la difficulté des exercices qu'il y a pu y avoir jusque là 🤔

On nous demande de créer un type générique qui représente une classe, donc instanciable avec new, et dont l'instanciation retourne une string qui concatène la clef et la valeur de l'objet passé en paramètre de type.

Solution

Il y avait deux étapes dans la résolution de ce problème :

  • l'extraction du couple clef/valeur dans une string
  • la création d'un type instanciable

Extraction de la clef/valeur

J'ai d'abord essayé avec le mot clef infer au sein d'un type conditionnel puisque ça me semblait être le plus adapté pour extraire la clef et la valeur d'un objet. J'étais quand même dubitatif puisqu'on n'avait pas encore rencontré de types conditionnels dans un contexte plus simple les jours précédents, et encore moins le mot clef infer.

Au premier jet j'obtiens ça :

type ExtractKeyValue<O> = O extends Record<infer K, infer V>
  ? K extends string
    ? V extends string
      ? `${K}: ${V}`
      : never
    : never
  : never;

type test = ExtractKeyValue<{foo: "bar"}>
type test = "foo: bar"

Avec O qui représente l'objet passé en paramètre de type, K qui représente sa clef (key), et V la valeur associée, toutes deux extraites grâce à infer.

Ca fonctionne, mais ça me semble bien trop compliqué. Il y a 3 conditions imbriquées, dont 2 juste pour savoir si la clef et la valeur sont bien des string.

Je me rends compte qu'on peut forcer le type de K et V au moment de l'inférence, ce qui simplifie un peu la solution :

type ExtractKeyValue<O> = O extends Record<infer K extends string, infer V extends string>
  ? `${K}: ${V}`
  : never;

type test = ExtractKeyValue<{foo: "bar"}>
type test = "foo: bar"

Mais je n'arrive pas à me débarasser de ce type conditionnel et de ces deux inférences pour la clef et la valeur. C'est dommage car quand j'y repense un peu plus tard dans la journée, ce que je veux faire semble en fait relativement simple, on a keyof O qui permettrait d'extraire la clef et O[keyof O] qui permettrait d'extraire la valeur. C'était même la toute première solution que j'avais essayé, avant de l'abandonner car je n'arrivais pas à contraindre keyof O en tant que string.

On voit dans les erreurs que keyof O est considéré comme étant string | number | symbol. Et ce, malgré la contrainte sur O pour qu'il soit un Record<string, string>. Vraiment dommage car ce serait pour le moment ma solution idéale, contrainte en entrée et simple dans l'implémentation.

D'ailleurs on voit que malgré l'erreur sur keyof O, quand on l'utilise avec un type qui a bien une string pour clef, ce type fonctionne comme prévu.


type ExtractKeyValue<O extends Record<string, string>> = `${keyof O}: ${O[keyof O]}`
Type 'keyof O' is not assignable to type 'string | number | bigint | boolean | null | undefined'. Type 'string | number | symbol' is not assignable to type 'string | number | bigint | boolean | null | undefined'. Type 'symbol' is not assignable to type 'string | number | bigint | boolean | null | undefined'.2322
Type 'keyof O' is not assignable to type 'string | number | bigint | boolean | null | undefined'. Type 'string | number | symbol' is not assignable to type 'string | number | bigint | boolean | null | undefined'. Type 'symbol' is not assignable to type 'string | number | bigint | boolean | null | undefined'. type test = ExtractKeyValue<{foo: "bar"}>
type test = "foo: bar"

Ca c'est encore quelque-chose que je ne comprends pas complètement. Je vois bien la situation où ExtractKeyValue<{123: "bar"}> ne provoquerait pas d'erreur car la clef 123 serait interprétée comme la string "123". Mais je ne comprends pas pourquoi TypeScript n'est pas capable d'en faire de même au sein d'un template literal.

Par contre après réflexion, je vois comment contourner ce problème. Avec le type utilitaire Extract, je peux m'assurer de ne récupérer que des strings.

type ExtractKeyValue<O extends Record<string, string>> = `${Extract<keyof O, string>}: ${O[keyof O]}`

type test = ExtractKeyValue<{foo: "bar"}>
type test = "foo: bar"

Création du type instanciable

Là j'ai dû chercher un peu dans la doc, car déjà j'utilise rarement les classes, mais alors créer un type qui représente une classe, je ne savais même pas que c'était possible.

J'ai fini par tomber là dessus : https://www.typescriptlang.org/docs/handbook/2/generics.html#generic-classes

Après quelques tentatives j'obtiens un type instanciable qui retourne strictement le paramètre de type qui lui est passé et qui ressemble à ça :

type Instanciable<T> = new (e: any) => T;

On récapitule

En combinant les deux morceaux de solution, on obtient ça :

type Excuse<T> = new (e: any) => ExtractKeyValue<T>

type ExtractKeyValue<T> = `${Extract<keyof T, string>}: ${Extract<T[keyof T], string>}`;

type test = ExtractKeyValue<typeof helpingTheReindeer>
type test = "helping: the reindeer" | "helping: bar" | "foo: the reindeer" | "foo: bar"
const helpingTheReindeer = { helping: 'the reindeer', foo: "bar" } as const;

Je me suis débarassé du extends Record<string, string> dans la définition de ExtractKeyValue au profit d'un 2e Extract, pour éviter d'avoir à également faire remonter cette contrainte au niveau du type Excuse.

Bien entendu cette solution ne fonctionne que pour des objets n'ayant qu'une clef et une valeur, sinon elle produit toutes les recombinaisons possibles d'associations clef/valeur de l'objet.

type test = ExtractKeyValue<typeof helpingTheReindeer>
type test = "helping: the reindeer" | "helping: bar" | "foo: the reindeer" | "foo: bar"
const helpingTheReindeer = { helping: 'the reindeer', foo: "bar" } as const;

Mais je n'ai pas encore trouvé comment écrire un type qui décrit un objet n'ayant strictement qu'une seule clef 🤷

À demain ! 🎅