TypeScript types avances - 11 - Symbols, unique symbol et opaque types

Comment utiliser Symbol et unique symbol pour créer des identifiants uniques au runtime et des types opaques en TypeScript.

11 - Symbols, unique symbol et opaque types

Ce que tu vas apprendre

  • Ce qu'est un Symbol en JavaScript et comment TypeScript le type
  • La différence entre symbol, unique symbol et Symbol.for
  • Comment utiliser unique symbol pour des branded types garantis uniques
  • Les well-known symbols (Symbol.iterator, Symbol.hasInstance)

Prerequisites

Avoir lu les articles sur les branded types et le déclaration merging.


Symbol : une valeur garantie unique

Un Symbol est un primitif JavaScript dont chaque instance est unique. Meme deux symbols créés avec la meme description sont différents :

typescriptconst a = Symbol("id")
const b = Symbol("id")

console.log(a === b) // false — toujours

C'est la seule valeur en JavaScript ou l'egalite est basee sur l'identité, pas sur le contenu. Les symbols sont utiles comme clés de propriétés garanties sans collision.

typescriptconst SECRET_KEY = Symbol("secret")

const obj = {
  [SECRET_KEY]: "hidden value",
  name: "visible"
}

console.log(obj[SECRET_KEY]) // "hidden value"
console.log(Object.keys(obj)) // ["name"] — le symbol n'apparait pas

Les propriétés dont la clé est un symbol ne sont pas enumerees par Object.keys, for...in, ou JSON.stringify. Elles sont accessibles uniquement si tu as la référencé au symbol.

unique symbol en TypeScript

En TypeScript, Symbol() a le type symbol. Mais si tu declares un symbol comme const, TypeScript lui donne le type unique symbol :

typescriptconst s1 = Symbol("id") // type: typeof s1 (unique symbol)
let s2 = Symbol("id")   // type: symbol

// unique symbol est plus precis que symbol
const s3: typeof s1 = s1 // ✅
const s4: typeof s1 = s2 // ❌ symbol n'est pas assignable a typeof s1

Un unique symbol est un type literal pour les symbols. Comme "hello" est un type literal pour string, typeof mySymbol est un type literal pour un symbol spécifique.

Declarer un unique symbol

typescript// Avec const
const MY_SYMBOL = Symbol("my")
// type: typeof MY_SYMBOL (unique symbol)

// Dans une interface
declare const TAG: unique symbol

interface Tagged {
  [TAG]: true
}

Le declare const TAG: unique symbol créé un type unique sans valeur runtime. C'est utile pour les branded types.

Branded types avec unique symbol

Dans l'article sur les branded types, on utilisait un string comme brand. Le risque : deux brands différents avec le meme string sont confondus.

typescript// ❌ Risque de collision
type Brand<T, B extends string> = T & { __brand: B }
type UserId = Brand<string, "Id">  // __brand: "Id"
type OrderId = Brand<string, "Id"> // __brand: "Id" — meme brand !

Avec unique symbol, chaque brand est garanti unique :

typescriptdeclare const UserIdBrand: unique symbol
declare const OrderIdBrand: unique symbol

type UserId = string & { readonly [UserIdBrand]: never }
type OrderId = string & { readonly [OrderIdBrand]: never }

const userId = "user_123" as UserId
const orderId = "order_456" as OrderId

function getUser(id: UserId) { /* ... */ }
getUser(userId)  // ✅
getUser(orderId) // ❌ garanti par le compilateur

Meme si les deux brands ont la meme structure (un symbol readonly avec le type never), les symbols sont différents, donc les types sont incompatibles. Aucun risque de collision.

Symbol.for : symbols partages

Symbol.for créé un symbol dans un registre global. Deux appels avec la meme clé retournent le meme symbol :

typescriptconst a = Symbol.for("app.userId")
const b = Symbol.for("app.userId")

console.log(a === b) // true

C'est utile pour partager des symbols entre modules ou entre packages. En TypeScript, Symbol.for retourne symbol (pas unique symbol) puisque la valeur est partageable.

Well-known symbols

JavaScript definit des symbols standards qui permettent de personnaliser le comportement des objets :

typescript// Symbol.iterator — rendre un objet iterable
class Range {
  constructor(private start: number, private end: number) {}

  [Symbol.iterator](): Iterator<number> {
    let current = this.start
    const end = this.end
    return {
      next() {
        if (current <= end) {
          return { value: current++, done: false }
        }
        return { value: undefined, done: true }
      }
    }
  }
}

for (const n of new Range(1, 5)) {
  console.log(n) // 1, 2, 3, 4, 5
}

Autres well-known symbols utiles :

typescript// Symbol.hasInstance — personnaliser instanceof
class Even {
  static [Symbol.hasInstance](value: unknown) {
    return typeof value === "number" && value % 2 === 0
  }
}

console.log(4 instanceof Even)  // true
console.log(3 instanceof Even)  // false

// Symbol.toPrimitive — personnaliser la conversion
class Money {
  constructor(public amount: number, public currency: string) {}

  [Symbol.toPrimitive](hint: string) {
    if (hint === "number") return this.amount
    return `${this.amount} ${this.currency}`
  }
}

Symbols comme clés privees (avant #private)

Avant les champs prives (#field), les symbols etaient la facon d'avoir des propriétés "presque privees" :

typescriptconst _balance = Symbol("balance")

class Account {
  [_balance]: number = 0

  deposit(amount: number) {
    this[_balance] += amount
  }

  getBalance() {
    return this[_balance]
  }
}

const account = new Account()
account.deposit(100)
// account[_balance] — accessible seulement si tu as la reference a _balance

Depuis TypeScript 4.3 et les champs prives ECMAScript (#), ce pattern est moins courant. Mais les symbols restent utiles quand tu veux une propriété qui n'est pas enumeree mais qui est accessible avec la bonne référencé.

Symbol dans les types

TypeScript utilise symbol comme type pour les clés :

typescripttype SymbolKeyed = {
  [key: symbol]: unknown
}

const key = Symbol("test")
const obj: SymbolKeyed = {
  [key]: "value"
}

Et keyof inclut les symbols :

typescriptconst a = Symbol("a")
const b = Symbol("b")

interface Foo {
  [a]: string
  [b]: number
  name: string
}

type Keys = keyof Foo // typeof a | typeof b | "name"

Sur paltemps.fr, j'utilise des symbols pour les metadonnees internes des entités (timestamps de cache, flags de validation) qui ne doivent pas apparaître dans les réponses JSON de l'API.


Résumé

  • Symbol() créé une valeur unique garantie — utile pour les clés de propriétés sans collision
  • unique symbol est le type literal d'un symbol spécifique, obtenu avec const
  • Les branded types avec unique symbol sont garantis uniques par le compilateur (pas de collision de strings)
  • Les well-known symbols (Symbol.iterator, Symbol.hasInstance) personnalisent le comportement des objets
  • Les propriétés a clés symbol ne sont pas enumerees par Object.keys ou JSON.stringify

Article précédent : 10 - Déclaration merging

Article suivant : 12 - Pattern matching type avec ts-pattern

Sources

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.