Type guard et assertion function en TypeScript
1. Vue d’ensemble
TypeScript affine les types (narrowing) très bien avec typeof, instanceof ou ===. Quand la logique de vérification devient plus complexe et que vous l’extrayez dans une fonction séparée, le compilateur ne « voit » plus le résultat de la vérification.
Cet article présente deux outils pour résoudre ce problème : le type guard (value is T) et l’assertion function (asserts value is T). Après lecture, vous saurez comment les écrire, valider les données unknown provenant d’une API, les tester, et identifier les cas où ils mentent silencieusement au compilateur.
2. Narrowing intégré et ses limites
Utilisons un domaine de commande comme exemple tout au long de l’article :
type PendingOrder = { status: "pending"; id: string; total: number };
type PaidOrder = { status: "paid"; id: string; total: number; paidAt: Date };
type CancelledOrder = { status: "cancelled"; id: string; reason: string };
type Order = PendingOrder | PaidOrder | CancelledOrder;
function describe(order: Order): string {
if (order.status === "paid") {
return `Commande ${order.id} payée le ${order.paidAt.toISOString()}`;
}
return `Commande ${order.id} en état ${order.status}`;
}
Order est une union discriminée avec le champ discriminant status. Dans la branche if, TypeScript sait que order est une PaidOrder, donc accéder à paidAt est valide.
Maintenant vous voulez réutiliser cette condition et l’extraire dans une fonction :
function isPaid(order: Order): boolean {
return order.status === "paid";
}
function describe(order: Order): string {
if (isPaid(order)) {
return `Payée le ${order.paidAt.toISOString()}`;
// ^^^^^^ erreur
}
return "Non payée";
}
error TS2339: Property 'paidAt' does not exist on type 'Order'.
Property 'paidAt' does not exist on type 'PendingOrder'.
La fonction isPaid retourne boolean. Le type boolean ne porte aucune information sur order, donc le compilateur ne peut pas affiner le type. C’est là qu’intervient le type guard.
3. Type guard défini par l’utilisateur
3.1. Syntaxe value is T
Il vous suffit de changer le type de retour de boolean à un type predicate :
function isPaidOrder(order: Order): order is PaidOrder {
return order.status === "paid";
}
function describe(order: Order): string {
if (isPaidOrder(order)) {
return `Payée le ${order.paidAt.toISOString()}`;
}
// ici order: PendingOrder | CancelledOrder
return `État : ${order.status}`;
}
console.log(describe({ status: "paid", id: "A1", total: 120, paidAt: new Date("2026-10-01") }));
console.log(describe({ status: "cancelled", id: "A2", reason: "Rupture de stock" }));
Payée le 2026-10-01T00:00:00.000Z
État : cancelled
Explications :
order is PaidOrdersignifie : si la fonction retournetrue, alorsordera le typePaidOrder.- À l’exécution, la fonction retourne toujours
true/false. La partieisn’existe qu’à la compilation. - Dans la branche
false, TypeScript exclutPaidOrderde l’union.orderdevientPendingOrder | CancelledOrder. - Le nom du paramètre dans le predicate (
order) doit correspondre à celui d’un paramètre de la fonction.
3.2. Filtrer un tableau avec un type guard
Les type guards sont particulièrement utiles avec Array.prototype.filter, car filter a un overload qui accepte un predicate :
const orders: Order[] = [
{ status: "pending", id: "A1", total: 50 },
{ status: "paid", id: "A2", total: 120, paidAt: new Date() },
{ status: "paid", id: "A3", total: 80, paidAt: new Date() },
];
const paidOrders = orders.filter(isPaidOrder); // PaidOrder[]
const revenue = paidOrders.reduce((sum, o) => sum + o.total, 0);
console.log(revenue);
200
paidOrders a le type PaidOrder[] et non Order[]. Grâce à cela, toutes les opérations suivantes peuvent accéder à paidAt sans transtypage.
Depuis TypeScript 5.5, le compilateur déduit automatiquement le type predicate pour les arrow functions simples. Par exemple, orders.filter(o => o.status === "paid") retourne aussi PaidOrder[]. Cette déduction fonctionne seulement si l’expression est suffisamment simple et que la relation « true ⇔ appartient au type » est correcte. Pour une logique plus complexe, vous devez écrire un type guard explicite.
3.3. Valider les données unknown
L’usage le plus pratique du type guard est de valider les données externes, par exemple du JSON reçu d’une API :
interface Customer {
id: string;
name: string;
email: string;
}
function isCustomer(value: unknown): value is Customer {
return (
typeof value === "object" &&
value !== null &&
"id" in value && typeof value.id === "string" &&
"name" in value && typeof value.name === "string" &&
"email" in value && typeof value.email === "string"
);
}
const raw: unknown = JSON.parse('{"id":"C1","name":"Lan","email":"lan@example.com"}');
if (isCustomer(raw)) {
console.log(raw.email.toUpperCase());
}
LAN@EXAMPLE.COM
Depuis TypeScript 4.9, l’opérateur in affine value vers object & Record<"id", unknown>. Vous pouvez donc écrire value.id sans as any. Chaque champ est vérifié à la fois pour son existence et son type.
3.4. Piège : le type guard ment
Le compilateur fait confiance aveuglément au type predicate. Il ne vérifie pas que le corps de la fonction correspond réellement à la promesse :
function isCustomerBroken(value: unknown): value is Customer {
return typeof value === "object" && value !== null; // oublie les champs
}
const data: unknown = { id: 1 };
if (isCustomerBroken(data)) {
console.log(data.email.toUpperCase()); // compile OK, plante à l'exécution
}
TypeError: Cannot read properties of undefined (reading 'toUpperCase')
Il y a un piège plus subtil dans la branche false :
function isLargeAmount(value: string | number): value is number {
return typeof value === "number" && value > 1000;
}
function format(value: string | number) {
if (isLargeAmount(value)) return `Grand : ${value}`;
return value.toUpperCase(); // TS pense que value: string
}
format(5); // TypeError: value.toUpperCase is not a function
La fonction retourne false pour le nombre 5. TypeScript en déduit que dans la branche restante, value n’est pas number. C’est faux.
Attention : Le type predicate est une relation bidirectionnelle. Retourner
truedoit signifier « appartient au type T ». Retournerfalsedoit signifier « n’appartient pas au type T ». Ne pas mettre de conditions métier (comme> 1000) dans un type guard. Extrayez-les dans une fonctionbooleanséparée.
4. Assertion function
4.1. asserts value is T
Le type guard vous force à encapsuler le code dans un if. Il y a des cas où vous voulez un comportement du genre « si c’est faux, lance une erreur, sinon continue ». C’est le rôle de l’assertion function :
function assertIsCustomer(value: unknown): asserts value is Customer {
if (!isCustomer(value)) {
throw new TypeError("Les données ne sont pas un Customer valide");
}
}
function greet(payload: unknown): string {
assertIsCustomer(payload);
// à partir d'ici, payload: Customer
return `Bienvenue ${payload.name}`;
}
console.log(greet({ id: "C1", name: "Minh", email: "minh@example.com" }));
console.log(greet({ id: "C2" }));
Bienvenue Minh
TypeError: Les données ne sont pas un Customer valide
Différences par rapport au type guard :
- Le type de retour est
asserts value is Customer. Le corps de la fonction ne retourne rien, c’est-à-dire que la fonction retournevoid. - La fonction lance une erreur si la condition est fausse. Si la fonction retourne normalement, TypeScript considère que la condition est vraie.
- Le type est affiné pour tout le code qui suit l’appel, pas seulement dans un bloc
if.
4.2. asserts condition
La deuxième forme ne se lie pas à un type spécifique. Elle affirme qu’une expression est truthy, similaire à l’assert de Node.js :
function assert(condition: unknown, message: string): asserts condition {
if (!condition) {
throw new Error(message);
}
}
function applyDiscount(order: Order, percent: number): number {
assert(order.status !== "cancelled", `Commande ${order.id} est annulée`);
// order: PendingOrder | PaidOrder → possède total
assert(percent > 0 && percent <= 50, "La réduction doit être entre (0, 50]");
return order.total * (1 - percent / 100);
}
console.log(applyDiscount({ status: "pending", id: "A1", total: 200 }, 10));
180
Après la ligne assert(order.status !== "cancelled", ...), TypeScript affine order de la même manière que dans un if. Donc order.total est valide, même si CancelledOrder n’a pas le champ total.
4.3. Erreur TS2775 : type annotation explicite manquante
C’est l’erreur la plus courante en écrivant une assertion function comme arrow function :
const assertPositive = (n: number): asserts n => {
if (n <= 0) throw new Error("Doit être positif");
};
assertPositive(5);
error TS2775: Assertions require every name in the call target
to be declared with an explicit type annotation.
Le compilateur doit connaître le type d’assertPositive au point de déclaration, il ne peut pas s’appuyer sur l’inférence. Vous avez deux solutions. La première est d’utiliser une déclaration function. La deuxième est d’ajouter le type à la variable :
type AssertPositive = (n: number) => asserts n;
const assertPositive: AssertPositive = (n) => {
if (n <= 0) throw new Error("Doit être positif");
};
Cette règle s’applique aussi aux méthodes appelées via un objet, par exemple validator.assertCustomer(x). La variable validator doit avoir un type déclaré explicitement, comme const validator: CustomerValidator = new CustomerValidator().
Attention : Une assertion function ne peut pas être
async. Le typePromise<asserts x is T>n’existe pas. Si vous avez besoin de valider de manière asynchrone, attendez d’abord les données, puis appelez une assertion function synchrone sur le résultat.
5. Tester les type guards et assertion functions
C’est de la logique à l’exécution, donc testez-la comme n’importe quelle fonction. Exemple avec Vitest :
npm install -D vitest typescript
// customer.test.ts
import { describe, it, expect } from "vitest";
import { isCustomer, assertIsCustomer } from "./customer";
describe("isCustomer", () => {
it("retourne true pour un object valide", () => {
expect(isCustomer({ id: "C1", name: "Lan", email: "lan@example.com" })).toBe(true);
});
it.each([
null,
"C1",
{ id: "C1", name: "Lan" },
{ id: 1, name: "Lan", email: "lan@example.com" },
])("retourne false pour %j", (input) => {
expect(isCustomer(input)).toBe(false);
});
});
describe("assertIsCustomer", () => {
it("lance TypeError pour les données incorrectes", () => {
expect(() => assertIsCustomer({ id: "C1" })).toThrow(TypeError);
});
it("affine le type quand les données sont correctes", () => {
const payload: unknown = { id: "C1", name: "Lan", email: "lan@example.com" };
assertIsCustomer(payload);
expect(payload.email).toBe("lan@example.com"); // compile grâce au narrowing
});
});
npx vitest run
✓ customer.test.ts (7 tests)
Test Files 1 passed (1)
Tests 7 passed (7)
Prêtez attention aux cas négatifs : champ manquant, mauvais type, null, valeurs primitives. La plupart des bugs des type guards viennent de cas où ils retournent true alors qu’ils ne devraient pas, comme dans l’exemple de la section 3.4. Le dernier test joue aussi un rôle de test à la compilation. Si quelqu’un change la signature de la fonction en void, le fichier de test ne compilera pas.
6. Choisir entre type guard et assertion function
| Situation | À utiliser |
|---|---|
| Les données invalides sont normales, besoin de gérer différentes branches | Type guard |
Filtrer un tableau, find, combiner avec filter |
Type guard |
| Les données invalides sont une erreur de programmation ou violation de contrat, il faut arrêter | Assertion function |
Vérifier les préconditions (précondition), éviter l’imbrication de if |
Assertion function |
Les deux outils vont souvent ensemble. Vous écrivez un type guard qui forme le cœur, puis vous l’enrobez dans une assertion function, comme assertIsCustomer appelle isCustomer. Ainsi la logique de validation ne se trouve qu’à un seul endroit.
Quand NE PAS écrire soi-même :
- Schéma volumineux ou profondément imbriqué. Écrire à la main
isCustomerpour un object avec 30 champs et des tableaux imbriqués, c’est facile d’en oublier. Une librairie de validation comme Zod ou Valibot génère le type et la validation à partir du même schéma, donc ils ne peuvent pas diverger. - Remplacer un
as. Un type guard avec un corps vide (return true) n’est pas plus sûr quevalue as Customer. C’est juste mieux caché. - Union discriminée simple. Si
if (order.status === "paid")est déjà clair, n’extrayez pas en fonction juste pour avoir une fonction.
7. Conclusion
Le type guard (value is T) vous permet d’encapsuler la logique de vérification de type dans une fonction tout en conservant le narrowing au point d’appel. Il fonctionne particulièrement bien avec if et filter. L’assertion function (asserts value is T ou asserts condition) affine le type pour tout le code qui suit en lançant une erreur si la condition est fausse. Elle convient pour vérifier les préconditions et les données d’entrée.
Les deux sont des promesses que le compilateur ne vérifie pas. Souvenez-vous de trois choses :
- Respecter la relation « true ⇔ appartient au type ».
- Déclarer le type explicitement pour une assertion function.
- Tester rigoureusement les cas négatifs.