25 juillet 2023
Les branded types avec TypeScript
3 minutes de lecture
Nous vous proposons dans cet article de découvrir un pattern avancé de TypeScript : les branded types. Nous allons voir comment les utiliser, et comment ils peuvent nous aider à améliorer la qualité de notre code.
Typage nominatif vs structurel
Afin de comprendre les branded types, il est important de comprendre la différence entre le typage nominatif et le typage structurel.
En TypeScript, le typage est dit structurel, c'est-à-dire que le compilateur va vérifier non pas le nom du type, mais sa structure. Ainsi TypeScript ne fait pas la différence entre ces deux types :
type Person = {
name: string
age: number
}
type Employee = {
name: string
age: number
}
Avec un language typé nominativement (comme PHP), ces deux types seraient différents car ils n'ont pas le même nom.
Le problème
Le fait que TypeScript soit un langage à typage structurel peut parfois poser problème. Prenons comme exemple ces deux types :
type AccountNumber = number
type PaymentAmount = number
Ils sont structurellement identiques, mais ne représentent pas (au sens métier) la même chose :
type AccountNumber = number
type PaymentAmount = number
function spend(accountNumber: AccountNumber, amount: PaymentAmount) {
// ...
}
const amount: PaymentAmount = 100
const accountNumber: AccountNumber = 321321
// 💥 Pas d'erreur TS alors que l'on a inversé des données
spend(amount, accountNumber)
Ici on a interverti les deux variables lors de l'appel de spend()
, problème : TypeScript ne lève pas d'erreur car les deux types sont bien structurellement identiques.
Il serait donc intéressant de pouvoir les différencier : c'est là qu'interviennent les branded types.
Les branded types
Grâce à une intersection, nous pouvons "tagguer" nos types afin de les différencier structurellement :
type AccountNumber = number & { __: 'AccountNumber' }
type PaymentAmount = number & { __: 'PaymentAmount' }
Nous pouvons ensuite créer des fonctions permettant de caster nos types grâce à un prédicat de type :
function isAccountNumber(accountNumber: number): accountNumber is AccountNumber {
return accountNumber.toString().length === 13
}
Ainsi le/la développeur(euse) sera obligé de passer par cette fonction afin de créer un AccountNumber
:
type AccountNumber = number & { _: 'AccountNumber' }
const accountNumber = 1263548749287
function logAccountNumber(accountNumber: AccountNumber) {
console.log(accountNumber)
}
// TypeScript lève une erreur
logAccountNumber(accountNumber)
// TypeScript ne lève plus d'erreur car accountNumber est désormais de type AccountNumber
logAccountNumber(isAccountNumber(accountNumber))
Exemple concret
Prenons un autre exemple plus parlant. Nous voulons obliger une personne à passer par une fonction permettant de valider un email avant de l'utiliser. Nous créons donc un type ValidEmail
:
type ValidEmail = string & { __: 'ValidEmail' }
Il est alors possible d'utiliser ce type pour s'assurer que la fonction isValidEmail()
a bien été appelée avant :
const isValidEmail = (email: string): email is ValidEmail => {
return email.includes('@')
}
const createUser = async (user: { email: ValidEmail }) => {
return user
}
export const onSubmit = async (values: { email: string }) => {
if (!isValidEmail(values.email)) {
throw new Error('Email is invalid')
}
await createUser({
email: values.email,
})
}
Si l'on retire la vérification isValidEmail
, TypeScript lèvera bien une erreur car la variable email
n'aura pas été castée en ValidEmail
:
export const onSubmit = async (values: { email: string }) => {
// 💥 Erreur TypeScript
await createUser({
email: values.email,
})
}
Conclusion
Les branded types (aussi connus sous le nom d'opaque, tagged, nominal types) permettent donc de simuler le typage nominatif en ajoutant "un tag" à la structure du type. Ce pattern permet de rajouter une couche de validation sur des parties de code sensible.
Si ce type de patterns vous intéresse, n'hésitez pas à participer à notre formation TypeScript !
À vos types ! 🛡