Romain Durand

Types Conditionnels Distributifs

À la découverte des types conditionnels distributifs (ou Distributive Conditional Types) !

TL;DR C'est une fonctionnalité de typescript un peu cachée qui permet de distribuer un type conditionnel sur chaque élément d'une union.

Un peu de contexte

Imaginons une API sur laquelle nous n'avons pas la main et qui renvoie un résultat de la forme :

type APIUsers = Array<{name: string} | {}>

async function fetchUsers(): Promise<APIUsers> {...}

Le fait qu'un user puisse être un objet vide est problématique, mais on peut aisément s'en protéger :

const users = await fetchUsers()

// on utilise une garde avec 'in' et non une simple vérification sur la
// véracité de 'user' car un objet vide est toujours évalué comme vrai
users.forEach(user => {
    if ('name' in user) {
        console.log(user.name)
        //               ^? string
    }
})

On pourrait aussi appliquer un filtre sur les users pour récupérer une liste d'objets non-vides, avec l'opérateur de prédicat de type is :

(avec l'arrivée de TypeScript 5.5, on devrait pouvoir se passer de is dans des cas simples comme celui-ci)

users.filter((user): user is {name: string} => {
    return 'name' in user
}).forEach(user => console.log(user.name))

Mais si cette API propose de plusieurs routes et autant de formats de retour (ex: APIPosts ou APITags), incluant chacun une union avec un objet vide, on pourrait souhaiter faire le ménage directement en sortie des appels à l'API, pour éviter d'avoir à ajouter plein de fois ce genre de garde autour de notre code métier. On voit aussi que les deux gardes que l'on vient d'écrire sont difficilement réutilisables pour autre chose que des users puisqu'elles vérifient l'existence d'un de ses champs.

Il faudrait écrire une fonction assez générique, que l'on pourrait appliquer directement sur le retour des appels à l'API.

async function fetchUsers() {
    // ...
    return filterEmptyObject(data)
}

Problématique

Comment exclure de façon générique un objet vide d'un résultat ?

Il faut distinguer deux choses, l'exclusion des objets vides au moment de l'exécution (runtime), et leur exclusion au niveau du typage.

  • Au niveau du runtime, rien de plus simple : un objet vide n'a pas de clés. On peut donc se contenter de compter ses clés et de l'exclure si il n'en a pas.
apiResult.filter(item => Object.keys(item).length)
  • Pour le typage c'est plus délicat, on ne peut pas juste Exclude un objet vide d'une union, vu qu'il n'a pas de propriété discriminante.
type test = Exclude<{} | {name:string}, {}>
//   ^? never

On peut facilement créer un type conditionnel pour vérifier si un type correspond ou non à un objet vide.

type CheckEmptyObject<T> = {} extends T ? "empty object" : "not empty object"

type testEmpty = CheckEmptyObject<{}>
//   ^? "empty object"
type testNotEmpty = CheckEmptyObject<{name:string}>
//   ^? "not empty object"

On peut traduire extends par "est-il équivalent à, ou plus large que". Ce type conditionnel pose donc la question "étant donné un type T, le type d'un objet vide est-il équivalent ou plus large que T ?". Le type d'un objet vide ne pouvant être plus large que n'importe quel autre type, la condition n'est vraie que quand T représente le type d'un objet vide.

Au lieu de répondre par "empty object" ou "not empty object", ou pourrait répondre par never ou T pour obtenir un type "filtrant".

type FilterEmptyObject<T> = {} extends T ? never : T

Ainsi, si on arrivait avec FilterEmptyObject à itérer sur les types d'une union {} | {name: string} | {published: boolean}, on obtiendrait never | {name: string} | {published: boolean}, ce qui serait résolu en {name: string} | {published: boolean}

Vous le voyez venir, c'est là qu'interviennent les types conditionnels distributifs !

Si on regarde la documentation :

Un type conditionnel dans lequel le type évalué est "nu" et vient d'un paramètre est appelé type conditionnel distributif. Les types conditionnels distributifs sont automatiquement distribués à travers les types d'une union au moment de l'instanciation. Par exemple, une instanciation de T extends U ? X : Y avec le type A | B | C pour l'argument T est résolu en (A extends U ? X : Y) | (B extends U ? X : Y) | (C extends U ? X : Y)

Ça mérite quelques explications : "le type évalué" est celui qui se trouve avant le extends, "nu" précise qu'il est utilisé tel quel dans l'évaluation. Par exemple si on avait

type MonTypeNonDistributif<T> = [T] extends ..., T n'est pas évalué "nu", car évalué comme faisant partie d'un tuple.

Si on reprend l'exemple donné, on aurait

type MonTypeDistributif<T> = T extends U ? X : Y

Si on développe l'exemple où T vaut A | B | C

type testDistributif = MonTypeDistributif<A | B | C>

// devient :

type testDistributif =
    | MonTypeDistributif<A>
    | MonTypeDistributif<B>
    | MonTypeDistributif<C>

// soit :

type testDistributif =
    | (A extends U ? X : Y)
    | (B extends U ? X : Y)
    | (C extends U ? X : Y)

Ça ressemble beaucoup à ce dont on a besoin ! La principale différence étant que dans FilterEmptyObject, le type évalué (celui qui se trouve avant le extends) est {} et non T, ce qui d'après la documentation n'en fait pas un type conditionnel distributif. Mais on peut contourner cette limitation en enveloppant FilterEmptyObject d'un type conditionnel évaluant T dont on peut prédire le résultat. Par exemple T extends T ? sera toujours vrai et permettra de déclencher dans TypeScript le comportement distributif. Dans le cas "vrai", on pourra utiliser FilterEmptyObject, et dans le cas "faux", qui ne sera jamais utilisé, se contenter de renvoyer never :

type ExcludeEmptyObjectFromUnion<T> = T extends T ? FilterEmptyObject<T> : never

Essayons de dérouler notre exemple précédent.

type test = ExcludeEmptyObjectFromUnion<{} | {name: string} | {published: boolean}>

Quand on distribue notre type conditionnel sur chaque type de l'union comme le montrait la documentation :

type test =
    | ExcludeEmptyObjectFromUnion<{}>
    | ExcludeEmptyObjectFromUnion<{name: string}>
    | ExcludeEmptyObjectFromUnion<{published: boolean}>

// soit :

type test =
    | ({} extends {} ? FilterEmptyObject<{}> : never)
    | ({name: string} extends {name: string} ? FilterEmptyObject<{name: string}> : never)
    | ({published: boolean} extends {published: boolean} ? FilterEmptyObject<{published: boolean}> : never)

Et en omettant la syntaxe T extends T ? ... : never (puisque toujours vraie) :

type test =
    | FilterEmptyObject<{}>
    | FilterEmptyObject<{name: string}>
    | FilterEmptyObject<{published: boolean}>

Une fois FilterEmptyObject évalué

type test = never | {name: string} | {published: boolean}
// qui est résolu en
type test = {name: string} | {published: boolean}

On a bien réussi à exclure, au niveau du typage, les objets vides de notre union.

Solution

type FilterEmptyObject<T> = {} extends T ? never : T
type ExcludeEmptyObjectFromUnion<T> = T extends T
    ? FilterEmptyObject<T>
    : never

async function fetchUsers() {
    const data: APIUsers = await (...)
    return data.filter(filterEmptyObject)
}

function filterEmptyObject<T extends object>(
    item: T,
): item is ExcludeEmptyObjectFromUnion<T> {
    return Object.keys(item).length > 0
}

const users = await fetchUsers()
//    ^? { name: string }[]

Conclusion

Notre fonction filterEmptyObject n'est pas liée au type APIUsers, on peut donc l'utiliser pour nettoyer chaque résultat de nos appels API.

Même si il est correctement inféré par TypeScript, vu qu'on a correctement fait le travail au niveau du typage de filterEmptyObject, on peut souhaiter être explicite sur le typage de retour de fetchUsers, et je ne rentrerai pas dans le débat de savoir si il faut ou non le faire ici, mais il s'écrirait comme ça :

Promise<Array<ExcludeEmptyObjectFromUnion<APIUsers[number]>>

Si je disais en introduction que les types conditionnels distributifs sont un peu cachés, c'est parce-que bien que décrits dans la documentation, il n'existe aucun marqueur syntaxique qui indique ce comportement. Une connaissance des types conditionnels ne permet pas non plus de déduire cette fonctionnalité. C'est un ensemble de conditions réunies lors de l'écriture d'un type conditionnel qui permet de déclencher ce comportement distributif.

La syntaxe T extends T peut sembler déroutante au premier abord, mais permet de créer ce marqueur syntaxique "manquant". J'aurais tendance à la préférer à T extends any (également toujours "vrai") qui peut-être retrouvée dans d'autres situations. T extends T exprime une fonction d'identité et pourrait être réservée pour indiquer l'intention de créer un type conditionnel distributif, mais c'est de l'ordre du choix et des conventions qu'on souhaite s'imposer.