IdentifiantMot de passe
Loading...
Mot de passe oublié ?Je m'inscris ! (gratuit)

Vous êtes nouveau sur Developpez.com ? Créez votre compte ou connectez-vous afin de pouvoir participer !

Vous devez avoir un compte Developpez.com et être connecté pour pouvoir participer aux discussions.

Vous n'avez pas encore de compte Developpez.com ? Créez-en un en quelques instants, c'est entièrement gratuit !

Si vous disposez déjà d'un compte et qu'il est bien activé, connectez-vous à l'aide du formulaire ci-dessous.

Identifiez-vous
Identifiant
Mot de passe
Mot de passe oublié ?
Créer un compte

L'inscription est gratuite et ne vous prendra que quelques instants !

Je m'inscris !

TypeScript, les types marqués : Produire un moyen de marquer un type, en fournissant un moyen automatisé et facile à utiliser pour rendre un type nominal
Par Prosopo

Le , par Prosopo

368PARTAGES

4  0 
Ohé les guerriers TypeScript ! 👋 Aujourd'hui, nous étendons notre travail dans l'article sur les types mappés TypeScript pour fournir une marque. L'article précédent traitait de la façon d'utiliser les types mappés TypeScript dans une nature nominale plutôt que structurelle.

Code : Sélectionner tout
1
2
3
4
5
6
7
type A = {
    x: number
}

type B = {
    x: number
}
C'est une façon élégante de dire que TypeScript est structurel par défaut, c'est-à-dire qu'il considère les types A et B comme égaux lorsqu'il s'agit de types. Rendre les types A et B nominaux amènerait TypeScript à les différencier, même si leur structure est la même.

Dans ce billet, nous nous appuyons sur ce travail pour produire un moyen de marquer un type, en fournissant un moyen automatisé et facile à utiliser pour rendre un type nominal. Le marquage se concentre uniquement sur le système de type, plutôt que d'introduire des champs d'exécution comme dans l'article précédent, ce qui est un avantage majeur par rapport à l'approche précédente.

Quel est le problème ?

Le marquage, également connu sous le nom de types opaques, permet de différencier les types dans TypeScript qui, autrement, seraient classés comme le même type. Par exemple

Code : Sélectionner tout
1
2
3
4
5
6
7
8
9
10
11
12
type A = {
    x: number,
    y: boolean,
    z: string,
}

type B = {
    x: number,
    y: boolean,
    z: string,
}
A et B sont structurellement identiques, TypeScript accepte donc n'importe quelle instance de A ou B à la place de l'une ou l'autre :

Code : Sélectionner tout
1
2
3
4
5
6
7
8
9
10
11
12
const fn = (a: A) => {
    console.log('do something with A')
}

const obj: B = {
    x: 1,
    y: true,
    z: 'hello'
}

fn(obj) // absolutely fine, even though fn accepts types of A and obj is of type B!
La fonction recherche une valeur de type A en entrée, alors que nous lui transmettons une valeur de type B. TypeScript compare les types structurellement, et parce qu'ils ont exactement la même structure, il considère que cette opération est correcte.

Mais que se passe-t-il si nous devons distinguer A et B ? Et si, d'un point de vue conceptuel, ils doivent être différents ? Et si nous faisons quelque chose de fantaisiste avec A et B dont TypeScript n'est pas au courant, mais que nous avons besoin que les types soient différents ? C'est exactement la situation dans laquelle nous nous sommes trouvés dernièrement !

Nous avons besoin d'une marque pour faire exactement cela.

La solution

Comme dans l'article sur les types mappés TypeScript, la clé réside dans la création d'un champ avec le nom d'un symbole qui servira d'identifiant. Cependant, avec le marquage, nous n'avons besoin de ce champ qu'au niveau du type plutôt qu'au niveau de l'exécution. Comme les types sont effacés après la compilation, nous devons ajouter ce champ à un type sans modifier les données d'exécution. Casting, quelqu'un ?

Tout d'abord, présentons le champ brand.

Code : Sélectionner tout
1
2
3
4
5
6
7
8
9
10
const brand = Symbol('brand') // keep this private!!

type A = {
    x: number,
    y: boolean,
    z: string,
} & {
    [brand]: 'A'
}
Ici, nous ajoutons le champ brand au type A. Le nom du champ marqué est un symbole, un peu comme un UUID. Nous utilisons un symbole pour nous assurer que le champ brand n'entre jamais en conflit avec un autre champ de A, car sinon nous écraserions un champ et introduirions les pires types de bogues : les bogues de type 🐛 . Pour l'instant, nous avons fixé la marque à "A", mais elle pourrait être ce que vous voulez. C'est un peu comme le nom du type. Comparons à nouveau A et B :

Code : Sélectionner tout
1
2
3
4
5
6
7
8
9
10
11
12
const fn = (a: A) => {
    console.log('do something with A')
}

const obj: B = {
    x: 1,
    y: true,
    z: 'hello'
}

fn(obj) // Error!
Voici l'erreur :

Code : Sélectionner tout
Argument of type 'B' is not assignable to parameter of type 'A'. Property '[brand]' is missing in type 'B' but required in type '{ [brand]: "a"; }'.ts(2345)
TypeScript ne nous permet pas de passer une instance de B à la fonction acceptant A parce qu'il lui manque le champ brand - génial ! A et B sont maintenant des types différents. Mais que se passerait-il si B avait sa propre marque ?

Code : Sélectionner tout
1
2
3
4
5
6
7
8
type B = {
    x: number,
    y: boolean,
    z: string,
} & {
    [brand]: 'B'
}
Notez que nous utilisons la même variable brand que précédemment. Il est important de garder cette variable constante, sinon nous déclarons des champs avec des noms différents !

Essayons maintenant la fonction à nouveau :

Code : Sélectionner tout
1
2
3
4
5
6
7
8
9
10
11
12
const fn = (a: A) => {
    console.log('do something with A')
}

const obj: B = {
    x: 1,
    y: true,
    z: 'hello'
}

fn(obj) // Error!
Et voici l'erreur

Code : Sélectionner tout
Argument of type 'B' is not assignable to parameter of type 'A'. Type 'B' is not assignable to type '{ [brand]: "A"; }'. Types of property '[brand]' are incompatible. Type '"B"' is not assignable to type '"A"'.ts(2345)
Nous y voilà ! L'erreur indique que bien que les deux types aient un champ brand, la valeur de la marque est différente pour les deux types, c'est-à-dire 'A' != 'B' !

Voyons ce qui se passe si la brand est la même :

Code : Sélectionner tout
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

type A = {
    x: number,
    y: boolean,
    z: string,
} & {
    [brand]: 'foobar'
}

type B = {
    x: number,
    y: boolean,
    z: string,
} & {
    [brand]: 'foobar'
}

const fn = (a: A) => {
    console.log('do something with A')
}

const obj: B = {
    x: 1,
    y: true,
    z: 'hello'
}

fn(obj) // absolutely fine!
Pas d'erreur ! A et B sont considérés comme des types interchangeables parce qu'ils ont la même structure, les mêmes champs et la même valeur de marque "foobar". Excellent !

Rendons-le générique !

Génial, ça marche. Mais c'est un exemple de jouet, qui n'est pas adapté à la production. Créons un type Brand qui peut marquer n'importe quel type :

Code : Sélectionner tout
1
2
3
4
5
const brand = Symbol('brand') // keep this private!

type Brand<T, U> = T & {
    [brand]: U
}
Ce type est très simple, il prend votre type T et ajoute un champ brand avec U étant la valeur de la marque. Voici comment l'utiliser :

Code : Sélectionner tout
1
2
3
4
5
6
7
8
9
10
11
12
13
type A_Unbranded = {
    x: number,
    y: boolean,
    z: string,
}

type A = Brand<A_Unbranded, 'A'> // {
//     x: number;
//     y: boolean;
//     z: string;
// } & {
//     [brand]: "A";
// }
Nous pouvons maintenant marquer n'importe quel type. Pour être complet, voici le même genre de chose pour enlever la marque et revenir à de simples types TypeScript :

Code : Sélectionner tout
type RemoveBrand<T> = T[Exclude]
Et ceci supprimera le champ brand de n'importe quel type marqué. Notez également que si le type n'est pas marqué, il ne sera pas touché !

Utilisation dans le monde réel

Mettons cela en pratique. Nous avons une classe qui a besoin d'une marque pour identifier son type lorsqu'elle traite avec des types mappés.

Pour plus de simplicité, réduisons la classe à une classe Dog :

Code : Sélectionner tout
1
2
3
4
5
6
7
8
class Dog {
    constructor(public name: string) {}
}

type DogBranded = Brand<Dog, 'Dog'>

const dog = new DogBranded('Spot') // Error!
TypeScript ne nous permettra pas de construire un Dog marqué &#128546; . Nous allons devoir faire un peu de casting en utilisant le constructeur pour marquer le constructeur plutôt que la classe elle-même.

Code : Sélectionner tout
1
2
3
4
5
6
7
8
9
10
type Ctor<T> = new (...args: any[]) => T

const addBrand = <T>(ctor: Ctor<T>, name: string) => {
    return ctor as Ctor<Brand<T, typeof name>>
}

const DogBranded = addBrand(Dog, 'Dog')

const dog = new DogBranded('Spot') // ok
La fonction addBrand prend le constructeur d'une classe et le transforme en un type marqué. Cela permet de créer un alias de la classe Dog qui peut être utilisé exactement de la même manière que la classe Dog, par exemple en appelant new.

Nous pouvons exporter (export) le type DogBranded pour permettre au monde extérieur d'utiliser notre classe tout en s'assurant qu'elle est toujours marquée :

Code : Sélectionner tout
export type DogExported = typeof DogBranded
De même, nous pouvons faire la même chose pour la suppression de la marque :

Code : Sélectionner tout
1
2
3
4
const removeBrand = <T>(value: T) => {
    return value as RemoveBrand<T>
}
Il suffit de supprimer la marque en transformant le type en un type mappé sans le champ de la marque.

Et voilà : un moyen sûr de marquer et de démasquer vos types en TypeScript &#128515;

Nous avons publié ce travail sous la forme d'une bibliothèque à laquelle vous pouvez accéder via NPM !

Chez Prosopo, nous utilisons le marquage TypeScript pour fortifier nos types et faire un mappage de type intelligent pour notre validateur de type d'exécution qui sera bientôt publié.

À propos de Prosopo

Prosopo est un projet open source dédié à la création d'un réseau de détection de bots alimenté par la communauté. Les services de détection de bots existants sont soit invasifs pour la vie privée, soit coûteux. Notre objectif est de changer cela en créant un réseau de sites web capables de détecter les bots sans compromettre la vie privée des utilisateurs. Plus il y a de sites web qui rejoignent le réseau, plus la détection des bots devient puissante.

Notre approche de la détection des bots s'inspire des systèmes CAPTCHA traditionnels, mais avec une nuance. Nous collectons le moins d'informations possible sur l'utilisateur, la plupart des détections étant effectuées dans le navigateur. Des preuves cryptographiques sont utilisées pour envoyer le minimum d'informations au serveur, et le serveur peut vérifier la preuve sans avoir besoin de connaître quoi que ce soit sur l'utilisateur.

Source : "TypeScript: Branded Types" (Proposo)

Et vous ?

Quel est votre avis sur le sujet ?

Voir aussi :

Apprendre les différentes fonctionnalités de TypeScript

Microsoft annonce la disponibilité de TypeScript 5.5 Beta. Cette version apporte les prédicats de type inférés et réduit le flux de controle pour les accès indexés constants

Cinq vérités inconfortables à propos de TypeScript selon Stefan Baumgartner, auteur de livres sur le langage de programmation

Une erreur dans cette actualité ? Signalez-nous-la !