Romain Durand

Je fais de la récursion en TypeScript sans condition de sortie, ÇA TOURNE BIEN ??

Sur les types mappés homomorphes (ou homomorphic mapped types)

TL;DR : Quand on créé un type mappé sur un paramètre de type, il est dit "homomorphe", ce qui signifie qu'il préserve le structure, ça vaut pour les objets, les tableaux, mais aussi (spoiler) pour les types primitifs.

Je voulais écrire un type qui corresponde à une fonction qui retire les éléments null d'une union, un peu comme dans le précédent article, mais cette fois de façon récursive.

J'ai commencé avec une base simple : un type conditionnel distributif qui exclue le type null d'une union.

type ExcludeNull<T> = T extends null? never : T;

type test = ExcludeNull<null | string | number>
//   ^? string | number

Au bout de quelques itérations et simplifications, j'arrive à cette version :

type NotNull<T> = T extends null
  ? never
  : { [K in keyof T]: NotNull<T[K]> };

Quand j'examine ce qu'elle produit sur un type de test, c'est bien le résultat attendu :

type test = NotNull<{
  // ^? { a: { pas: "nul" }; b: { c: { pas: "nul" }; d: { pas: "nul" }[] } }
  a: null | { pas: 'nul' };
  b: {
    c: null | { pas: 'nul' };
    d: Array<null | { pas: 'nul' }> ;
  };
}>;

Pourquoi ça marche ?

Ça fonctionne comme prévu, n'a plus l'air vraiment simplifiable, mais en la relisant, je me rends compte que ça ne devrait pas marcher :

  • T extends null ?

    On évalue simplement T qui vient des paramètres, ce qui en fait un type conditionnel distributif

  • never

    Si il est distribué sur un membre d'une union qui vaut null, ce membre sera exclu de cette union

  • { [K in keyof T]: NotNull<T[K]> }

    Sinon on appelle récursivement NotNull sur chaque clé de T avec un type mappé. Cette syntaxe prend aussi bien en compte les objets que les tableaux. Je vois cette opération comme un Object.keys suivi d'un .map() mais pour les types.

Pour résumer, quand on a null, on sort, sinon on continue la récursion plus profondément.

Mais alors quelle est la condition de fin de récursion pour les types non-nulls ?

Après avoir relu plusieurs fois la documentation des types mappés, j'ai fini par trouver plusieurs posts qui renvoyaient vers cette section de la FAQ TypeScript : Common "Bugs" That Aren't Bugs qui pour l'entrée "This mapped type returns a primitive type, not an object type." précise :

Les types mappés déclarés { [ K in keyof T ]: U }T est un paramètre sont appelés types mappés homomorphes (NDT: "homomorphic mapped type"), ce qui signifie que le type mappé sera une fonction qui préservera la structure de T. Quand le paramètre de type T est instancié avec un type primitif, le type mappé est évalué comme étant cette même primitive.

Le mystère est résolu : les types primitifs (et donc les types littéraux qui n'en sont qu'un sous-ensemble) sont retournés tels quels par les types mappés homomorphes. Ce qui peut se justifier a posteriori : "homomorphe" préserve la structure, la forme, or la forme d'un type primitif ou littéral, c'est juste lui même. La condition de sortie de récursion qui nous manquait se cachait donc ici.

Un fonctionnent trop peu intuitif ?

Certains regrettent que ce comportement soit peu intuitif, et auraient réécrit le type ainsi :

// 1- vérification de null
type NotNull<T> = T extends null
  // 2- exclusion de null
  ? never 
  // 3- sinon vérification d'objet/tableau
  : T extends object
    // 4- récursion dans l'objet tableau
    ? { [K in keyof T]: NotNull<T[K]> }
    // 5- sortie de la récursion
    : T

C'est plus verbeux mais beaucoup moins déroutant pour quelqu'un qui ne connaîtrait pas la propriété homomorphe de ce genre de types mappés.Si je suis très favorable aux pratiques qui rendent un code plus accessible, je me dis aussi qu'il est important de connaitre ce genre de détail de fonctionnement d'un langage, donc pas d'avis arrêté sur la syntaxe à adopter pour le moment.

J'ai menti

En réalité, un type utilitaire qui rendrait "non-nullable" la propriété a d'un type d'objet

{ a: null | {pas : "nul"} }

ne m'intéressait pas vraiment : si une propriété est "nullable" et que mon code doit y accéder, utiliser garde (condition ou chaînage conditionnel) est nécessaire. Je ne souhaite en fait appliquer ce type qu'aux tableaux pour filtrer au niveau du typage ce qu'un .filter(i => i === null) filtrerait au moment de l'exécution.

On repart donc du type conditionnel distributif qui exclue null d'une union.

type ExcludeNull<T> = T extends null ? never : T;

Ensuite on créé un autre pour savoir si on traite un tableau :

export type DeepExcludeNullFromArrays<T> = T extends any[]

Si T est un tableau, on va chercher l'union des types possibles de ses éléments avec [number], on en exclue null en l'enveloppant avec (ExcludeNull<...>), puis on retourne cette union sous la forme d'un tableau en la suffixant avec [].

  ? ExcludeNull<T[number]>[]

Si T n'est pas un tableau, on appelle DeepExcludeNullFromArrays récursivement avec un type mappé homomorphe, maintenant que l'on sait qu'il nous fera sortir tout seul de la récursion en bout de chaîne.

type ExcludeNull<T> = T extends null ? never : T;

export type DeepExcludeNullFromArrays<T> = T extends any[]
  ? ExcludeNull<T[number]>[]
  : { [K in keyof T]: DeepExcludeNullFromArrays<T[K]> };

Ce qui fonctionne … tant que l'on n'a pas des tableaux imbriqués dans des tableaux :

type test = DeepExcludeNullFromArrays<{
  a: Array<null | {deepArray: Array<null | {pas: "nul"}>}>
}>
//   ^? { a: { deepArray: (null | { pas: "nul" })[] }[] }

En effet, si a n'est plus nullable, on voit que notre deepArray peut encore contenir des valeurs qui le sont. C'est attendu : dans le cas où T est un tableau, DeepExcludeNullFromArrays appelle ExcludeNull qui retourne un type, il n'y a pas de notion de récursion qui rentre en ligne de compte.

Heureusement, le correctif est facile, au lieu de retourner T dans ExcludeNull (qui correspondrait au T[number] de DeepExcludeNullFromArrays et n'aurait possiblement jamais pu être transformé par notre code), on retourne DeepExcludeNullFromArrays<T>. Oui, ça nous donne deux types mutuellement récursifs (dont un qui le devient doublement), mais ça correspond bien à ce qu'on cherche à faire et surtout, ça fonctionne !

export type ExcludeNull<T> = T extends null
  ? never
  : DeepExcludeNullFromArrays<T>;

export type DeepExcludeNullFromArrays<T> = T extends any[]
  ? ExcludeNull<T[number]>[]
  : { [K in keyof T]: DeepExcludeNullFromArrays<T[K]> };

type test = DeepExcludeNullFromArrays<{
//   ^? { a: { deepArray: { pas: "nul" }[] }[] }
  a: Array<null | { deepArray: Array<null | { pas: 'nul' }> }>
}>;

Une implémentation de la fonction javascript correspondant à ce type serait :

function deepExcludeNullFromArrays<T>(obj: T): DeepExcludeNullFromArrays<T> {
  if (obj === null) {
    return obj as DeepExcludeNullFromArrays<T>;
  }
  if (Array.isArray(obj)) {
    return obj
      .filter((item) => item !== null)
      .map(deepExcludeNullFromArrays) as DeepExcludeNullFromArrays<T>;
  }
  if (typeof obj === 'object') {
    return Object.fromEntries(
      Object.entries(obj).map(([key, value]) => [key, deepExcludeNullFromArrays(value)]),
    ) as DeepExcludeNullFromArrays<T>;
  }
  return obj as DeepExcludeNullFromArrays<T>;
}