Accueil / Articles / Identifiants marqués en TypeScript : quels encodages empêchent réellement une suppression incorrecte

Identifiants marqués en TypeScript : quels encodages empêchent réellement une suppression incorrecte

Six manières de saisir UserId et InvoiceId comparées dans un seul test : lesquelles font que tsc rejette deleteInvoice(userId), et où Zod apporte une sécurité en temps de exécution.

2472 mots

Imaginez un outil appelé deleteInvoice dont le premier paramètre devrait être un identifiant de facture, et un point d’appel qui transmet plutôt un identifiant d’utilisateur. Si tsc se termine avec un code de sortie 0, les types d’identifiants que vous avez déclarés ne sont rien de plus que de la documentation, et la documentation n’a jamais empêché une requête destructrice. Ce guide effectue une expérience simple sur six méthodes populaires d’encodage de UserId et InvoiceId, montre quelles d’entre elles font refuser au compilateur l’appel incorrect, et se termine par une politique pratique concernant l’endroit où marquer les identifiants, où les valider en temps de exécution, et comment détecter les conversions de type qui annulent silencieusement tout cela.

Pourquoi deux alias de chaîne sont du même type

Le système de types de TypeScript est structural. Deux types d’objets ayant la même structure sont interchangeables, et deux alias de string ne constituent pas du tout deux types différents : il s’agit du même string sous des noms différents. Le vérificateur ne dispose d’aucun critère pour les distinguer.

Les marques résolvent ce problème en ajoutant une propriété fantôme au type. Cette propriété n’existe jamais en temps de exécution ; elle existe uniquement pour que le vérificateur considère UserId et InvoiceId comme ayant des structures différentes. Les marques par intersection, les marques avec unique symbol, les préfixes de littéraux de template ainsi que la méthode .brand() de Zod sont toutes des variantes de cette même astuce.

Deux opérateurs souvent confondus avec des marques sont inclus dans les comparaisons précisément en raison de cette confusion : satisfies et as const. Aucun d’eux ne crée un type distinct.

Gardez une chose en tête à tout moment : une fois TypeScript retiré, toute encodage suivant correspond à une simple chaîne de caractères. Node n’a aucune idée que de tels types ont existé. La seule protection dont vous disposez provient des vérifications effectuées par le compilateur en temps de compilation, ainsi que des validations en temps d’exécution que vous ajoutez explicitement.

Le test : une appel illégal

Tous les encodages font face au même point d’appel. Une fonction attend un InvoiceId, tandis qu’une valeur de type UserId provient d’un autre endroit ; la question est de savoir si le compilateur s’y oppose.

declare function deleteInvoice(id: InvoiceId): Promise<void>;
const userId = getUserId(); // UserId
await deleteInvoice(userId);

1. Aliases de type simples

C’est avec cela que la plupart des bases de code commencent.

type UserId = string;
type InvoiceId = string;

Cela se compile, et la ligne incorrecte disparaît. Comme les deux noms se résolvent en string, le compilateur n’a aucune raison de s’opposer. C’est l’échec de base que les autres options tentent de corriger.

2. Types littéraux avec as const

Ici, l’identifiant utilisateur est un littéral et l’identifiant de facture est un type de littéral de template avec un préfixe obligatoire.

const userId = "usr_123" as const;
type InvoiceId = `inv_${string}`;

Cela ne fonctionne que dans un cas très restreint. Si userId possède réellement le type littéral "usr_123", il ne peut pas être assigné à `inv_${string}` et l’appel est rejeté. Mais les identifiants réels proviennent de fonctions, de requêtes et de bases de données, et un getter comme getUserId() renvoie généralement une string. Dès que cela se produit, on revient à l’option 1. Ajouter as const à quelque chose déjà de type string ne le restreint pas en rien d’utile. Notez également que c’est le type de littéral de template qui effectue réellement le travail ici, et non as const.

3. satisfait la condition string

Cette structure apparaît dans les revues de code présentées comme une mesure de sécurité.

const userId = getUserId() satisfies string;

Cela se compile. satisfies vérifie que une expression correspond à un type tout en conservant le type déduit de l’expression elle-même ; il n’introduit jamais de nouveau type nominal. C’est un opérateur utile, plus proche d’un correcteur orthographique qu’d’une marque, et il ne offre aucune protection contre l’envoi d’un identifiant incorrect.

4. Marque d’intersection

L’intersection d’une string avec un objet possédant un champ __brand de lecture seule confère à chaque identifiant une forme distincte.

type UserId = string & { readonly __brand: "UserId" };
type InvoiceId = string & { readonly __brand: "InvoiceId" };

Désormais tsc rejette deleteInvoice(userId). C’est la version qui fonctionne sans aucune bibliothèque. Le inconvénient est que les chaînes de caractères brutes ne conviennent plus, ce qui oblige chaque type marqué à disposer d’un constructeur capable de transformer une chaîne validée en la marque correspondante :

function asUserId(raw: string): UserId {
  if (!raw.startsWith("usr_")) throw new Error("not a user id");
  return raw as UserId;
}

Ce as à l’intérieur du constructeur représente la faille inévitable. Si le constructeur est public et ne effectue aucune vérification, il devient un outil permettant d’attribuer des étiquettes fausses. Vérifier l’existence d’un préfixe constitue une vérification raisonnable lorsque vos identifiants possèdent réellement des préfixes. Si vos identifiants sont des UUID sans préfixe, ne modifiez pas le format de stockage uniquement pour permettre cette vérification ; validez plutôt ce qui est véritablement vrai concernant la valeur, comme le format UUID ou le fait qu’elle ait été lue directement dans la table des factures.

5. symbole unique de marque

Au lieu d’une propriété nommée en chaîne de caractères, la clé de marque est un symbole unique déclaré une seule fois.

declare const invoiceBrand: unique symbol;
type InvoiceId = string & { [invoiceBrand]: true };

Le compilateur refuse cette appelation incorrecte, exactement comme pour l’option 4. Comme le symbole est déclaré dans un module, il est un peu plus difficile pour un autre fichier de falsifier la marque en créant un type d’objet avec la même clé. Le compromis réside dans la lisibilité : ce schéma nécessite plus d’explications dans une demande de fusion que la version __brand.

6. La marque Zod

Zod peut associer une marque au type qu’il infère et, contrairement à toutes les options précédentes, il peut également vérifier la valeur en temps de exécution.

const InvoiceId = z.string().startsWith("inv_").brand<"InvoiceId">();
type InvoiceId = z.infer<typeof InvoiceId>;

L’appel utilisant un UserId de la marque Zod échoue au contrôle de type, et l’analyse de usr_123 selon le schéma de facture échoue en temps de exécution car la règle startsWith("inv_") la rejette. C’est ce contrôle en temps de exécution que les encodages purement statiques ne peuvent pas fournir : une valeur provenant d’une source non fiable, même déjà mal étiquetée, est détectée lorsqu’elle passe par la fonction parse. Une conversion manuelle en as InvoiceId ailleurs contourne néanmoins complètement Zod, de sorte que la protection ne s’applique qu’aux valeurs qui passent réellement par le schéma.

Tableau de score

  • Alias simples : compilation réussie, mais la suppression incorrecte a lieu.
  • as const : compilation possible dès que la valeur source est de type string.
  • satisfies string : compilation possible.
  • Marque d’intersection : rejetée par tsc.
  • unique symbol : rejeté par tsc.
  • Zod : rejeté par tsc, et un identifiant d’utilisateur brut est également rejeté en temps de exécution par parse.
  • En d’autres termes, les trois options que les gens considèrent souvent comme permettant de saisir leurs identifiants ne résolvent rien pour ce bug, tandis que les trois formats officiels l’empêchent lorsqu’ils sont utilisés correctement.

    Réproduire la comparaison dans votre propre projet

    Mettez les six encodages dans un fichier comme src/ids.ts, ajoutez l’appel illégal deleteInvoice(userId) pour chacun d’eux, puis exécutez le compilateur sans afficher de sortie :

    pnpm exec tsc --noEmit
    

    Ensuite, prenez un identifiant d’utilisateur et passez-le par le schéma Zod, de la même manière qu’une valeur volée ou erronée arriverait d’une requête :

    InvoiceId.parse(String(userId));
    

    Si cette analyse réussit, le format en question n’est qu’une étiquette sans condition associée.

    Ne testez pas le système de marquage en écrivant id as InvoiceId juste à côté de la définition. Un cast se compile toujours, donc ce test ne prouve rien.

    Identifier les casts qui vous mettent déjà en difficulté

    Les marquages ne sont aussi forts que le nombre d’endroits où ils sont contournés. Recherchez les casts directs :

    rg "as InvoiceId|as UserId" src app
    

    Une longue liste signifie que le marquage n’est pour l’essentiel qu’une décoration. Corrigez les constructeurs et le traitement des limites avant d’introduire davantage de types marqués.

    Ce que le vérificateur peut et ne peut pas garantir

    Les marquages sont virtuels : le JavaScript généré reste une string. Le vérificateur ne vous protège qu’au point d’appel si la valeur n’a jamais été transmise via as InvoiceId et n’est jamais passée par une fonction qui accepte simplement une string et renvoie le marquage sans vérification.

    satisfies reste le faux type le plus courant dans les avis. C’est un bon outil pour ce à quoi il est destiné, mais la saisie nominale n’en fait pas partie.

    Les types littéraux de template tels que `inv_${string}` se comportent un peu comme des types nominaux et ont l’avantage de documenter le préfixe directement dans le type. Ils ne fonctionnent pas lorsque les identifiants sont des UUID sans préfixe. Il faut adapter le type aux données, et non la base de données au type.

    La séparation qui se révèle efficace en pratique consiste à utiliser les marques Zod à la frontière publique et des marques de type intersection à l’intérieur de l’application. Effectuez une analyse une seule fois lorsque les données arrivent, par exemple dans un gestionnaire de requêtes, et laissez le type marqué assurer la garantie à l’intérieur. Réanalyser à chaque étape entre une route comme /invoices et un travailleur en arrière-plan ne fait qu’augmenter les coûts. Pour une méthode visant à centraliser cette frontière, consultez comment protéger la frontière Express avec un seul middleware Zod.

    Quels sont les coûts liés à la marque

    • Constructeurs. Chaque marque de type intersection en nécessite un. Deux types d’identifiants signifient deux petites fonctions, et non vingt.
    • Fausse confiance. Un simple as InvoiceId placé juste après JSON.parse annule silencieusement la protection pour tout ce qui suit.
  • Analyse en temps de exécution. Zod remplit deux fonctions à la fois : validation et étiquetage, et vous payez pour chaque analyse effectuée au moment de l’entrée des données. Cela en vaut la peine aux frontières publiques, mais est généralement trop lourd pour les transferts internes une fois que les données ont déjà été vérifiées. Sur des chemins fréquemment empruntés, mesurez le coût associé.
  • Les avantages. Une seule appelation illégale qui se compile peut entraîner la suppression d’une ligne que vous ne pourrez pas restaurer. En revanche, une exécution échouée de tsc ne coûte rien, et c’est là toute la valeur du champ fantôme.
  • Une défaillance réelle et la solution efficace

    Imaginons un outil de support interne où UserId et InvoiceId étaient déclarés comme type X = string. Une page consacrée à un utilisateur contient son identifiant dans l’URL, et une action de suppression sur cette page lit cet identifiant depuis l’URL pour le transmettre à deleteInvoice. Le code se compile, mais c’est un enregistrement d’utilisateur qui disparaît au lieu d’une facture.

    La solution tentante consiste à renommer les paramètres pour rendre l’intention plus claire. Cela ne sert à rien : le site qui effectue une appel imprudent suivant se compile tout aussi bien.

    La solution fiable est d’utiliser des types d’intersection et un constructeur de validation pour identifier les éléments au sein de l’application :

    type InvoiceId = string & { readonly __brand: "InvoiceId" };
    function asInvoiceId(raw: string): InvoiceId {
      if (!raw.startsWith("inv_")) throw new Error("not an invoice id");
      return raw as InvoiceId;
    }
    

    Et, au niveau du protocole HTTP, un schéma Zod qui sert à la fois à valider et à identifier les données :

    const InvoiceId = z.string().startsWith("inv_").brand<"InvoiceId">();
    

    Après ce changement, le bouton de suppression d’une ligne de facture obtient son identifiant via asInvoiceId à partir d’un champ qui contient réellement l’ID de la facture. L’écran destiné à l’utilisateur peut conserver son ID dans l’URL, car cet écran concerne justement l’utilisateur. Le type de données aurait permis de détecter rapidement le problème avec l’ancien outil d’aide ; une étiquetage plus clair aurait eu le même effet. L’équipe ne disposait ni de l’un ni de l’autre.

    Vérification finale : une recherche de as InvoiceId dans le code source ne devrait rien renvoyer, ou seulement des résultats parfaitement justifiés.

    Rendre la vérification reproductible

    Une routine de vérification courte et répétable empêche que ces résultats ne deviennent du folklore. Commencez par enregistrer les versions des outils, car leur comportement peut changer entre les mises à jour majeures. La configuration de référence pour cette comparaison était une petite application de facturation à quatre routes sur Node 24, TypeScript 7 et Next.js 16.3 ; vérifiez les versions dans votre propre projet avant de comparer les résultats.

    node -v
    pnpm exec tsc -v
    pnpm exec next --version
    

    Si une version majeure diffère de ce à quoi vous vous attendiez, hésitez avant de faire confiance aux résultats ultérieurs. Lancez ensuite l’application et testez les routes concernées :

    pnpm exec next dev
    

    Visitez /, /invoices, /invoices/1, /settings et à nouveau /invoices en activant la conservation des journaux dans DevTools, afin de voir quel identifiant chaque écran porte réellement dans son URL.

    Finalement, exécutez le vérificateur de types sous une forme adaptée aux scripts et examinez l’état de sortie :

    pnpm exec tsc --noEmit --pretty false
    echo $?
    

    Modes courants par lesquels les marques échouent

    • as InvoiceId directement après JSON.parse : la marque devient alors un simple élément décoratif.
    • satisfies string accepté lors de l’examen comme s’il s’agissait d’une marque : il ne vérifie que la conformité.
    • Un préfixe de littéral de template sur une colonne UUID, suivi par quelqu’un qui ajoute ce préfixe aux données stockées pour que le type corresponde. Il faut revenir à la configuration initiale. Marquez plutôt la valeur analysée plutôt que de modifier la base de données pour s’adapter à un type donné.
  • Un constructeur tel que asInvoiceId exporté depuis un fichier barrel, rendu ainsi facilement accessible à tout module souhaitant contourner la validation.
  • Liste de vérification avant d’appeler une identité de type spécifié

    • Elle n’est pas déclarée comme type FooId = string.
    • satisfies string n’est pas son unique condition de validation.
    • Un constructeur ou une analyse Zod la protège à l’entrée.
    • deleteInvoice(userId) provoque une erreur avec tsc.
    • Les résultats de la recherche pour as InvoiceId forment une liste courte que l’on peut justifier.

    Le contexte est également important. Il n’est pas nécessaire de marquer chaque chaîne de caractères dans le répertoire. Marquez uniquement les identités susceptibles de détruire ou d’exposer des données : les chemins liés à la suppression, au remboursement et à l’impersonation sont de bons premiers candidats. Si vous en arrivez à cinquante marques, vous vous contentez de décorer plutôt que de protéger.

    Un ensemble compact de commandes couvre les vérifications en cours, y compris la recherche d’alias d’ID en chaîne de caractères non utilisés :

    pnpm exec tsc --noEmit
    rg "as InvoiceId" src
    rg "type \w+Id = string" src
    

    La requête illégale deleteInvoice(userId) doit figurer dans un fichier de test où l’on s’attend à ce que les vérifications de type échouent (par exemple avec un commentaire @ts-expect-error au-dessus), et jamais dans du code de production comme lib/delete.ts.

    Essayez-le sur votre base de code

    Écrivez l’appel illégal deleteInvoice(userId) à côté de l’outil de suppression que vous utilisez réellement, à un endroit où le compilateur effectue des vérifications. Si tsc reste silencieux, vos identifiants ne sont que des commentaires. Convertissez InvoiceId en type d’intersection et assurez-vous que l’appel devienne rouge. Ajoutez un constructeur de validation ou un type Zod à la frontière HTTP et vérifiez qu’un identifiant brut tel que „usr_123“ provoque une erreur. Ensuite, recherchez as InvoiceId et justifiez chaque occurrence dans la demande de fusion ou supprimez-la.

    Points clés

    • Les alias, as const et satisfies ne créent pas de types distincts, donc ils ne peuvent pas empêcher l’utilisation d’un identifiant incorrect.
    • Les types d’intersection et les unique symbol font en sorte que le compilateur rejette les appels erronés ; les types Zod ajoutent une vérification en temps de exécution pour les valeurs qui passent par parse.
  • Chaque marque dispose d’une issue de secours dans as. Conservez les conversions à l’intérieur de constructeurs de validation simples et auditez le reste.
  • Analyssez et appliquez la marque une seule fois à la frontière, transférez la marque statique vers l’intérieur, et réservez l’application de la marque aux identifiants dont une utilisation incorrecte provoque des dommages irréversibles.