Type guards TypeScript : typeof, instanceof, in operator, predicates, assertion functions, discriminated unions, Zod/Valibot et control flow analysis.
Pourquoi le narrowing existe
TypeScript utilise un système de types structurel et statique. À la compilation, il ne peut pas savoir quelle valeur contiendra une variable de type union (string | number) ou un type large (unknown, any). Le narrowing est le mécanisme par lequel TypeScript réduit le type d'une variable dans une branche de code en analysant les conditions que tu écris.
function formatValue(value: string | number | boolean): string {
// Ici: value est string | number | boolean
// value.toUpperCase() → ERREUR: number et boolean n'ont pas toUpperCase
if (typeof value === 'string') {
// Ici: TypeScript sait que value est string
return value.toUpperCase();
}
if (typeof value === 'number') {
// Ici: TypeScript sait que value est number
return value.toFixed(2);
}
// Ici: TypeScript a éliminé string et number → value est boolean
return value ? 'Oui' : 'Non';
}
TypeScript appelle cela le control flow analysis : il suit chaque branche if/else/switch pour déterminer quels types sont encore possibles à chaque point du code. Ce mécanisme est automatique et ne nécessite pas de cast forcé (as).
as SomeType (cast) est un aveu que tu contournes TypeScript. Cherche d'abord un type guard approprié — ils sont plus sûrs car vérifiés à l'exécution.
typeof — primitives et limites
typeof retourne une chaîne parmi "string", "number", "bigint", "boolean", "symbol", "undefined", "object", "function". TypeScript reconnaît ces comparaisons et narrow en conséquence.
function serialize(value: string | number | null | undefined): string {
// Guard null et undefined en premier
if (value == null) { // == couvre null ET undefined
return '';
}
// Ici: value est string | number (null et undefined éliminés)
if (typeof value === 'string') {
return JSON.stringify(value); // value: string
}
return value.toString(); // value: number (seul cas restant)
}
// typeof ne distingue pas les objets entre eux
function processObject(x: Date | RegExp | null) {
if (typeof x === 'object' && x !== null) {
// x est encore Date | RegExp — typeof 'object' est trop large
// typeof ne permet pas de distinguer Date de RegExp
// → utiliser instanceof à la place
}
}
Limite importante : typeof null === "object"
// Piège classique hérité de JavaScript
const x: string | null = getValueOrNull();
if (typeof x === 'object') {
// TypeScript narrow x à null (pas à string | null)
// Car typeof null === 'object' ET typeof string !== 'object'
console.log(x); // x: null ici
}
// Bonne pratique : toujours vérifier null explicitement
if (x !== null) {
// x: string (null éliminé par !== null)
console.log(x.toUpperCase()); // OK
}
instanceof — classes et hiérarchies
instanceof vérifie la chaîne de prototypes. TypeScript narrow le type vers la classe testée. Fonctionne avec les classes natives (Date, Error, Map, Set) et les classes personnalisées.
// Hiérarchie de classes — instanceof respecte l'héritage
class HttpError extends Error {
constructor(
public statusCode: number,
message: string
) {
super(message);
this.name = 'HttpError';
}
}
class NetworkError extends Error {
constructor(
public isOffline: boolean,
message: string
) {
super(message);
this.name = 'NetworkError';
}
}
function handleError(error: unknown): string {
// unknown force à narrow avant d'accéder aux propriétés
if (error instanceof HttpError) {
// error: HttpError — accès à statusCode OK
return `HTTP ${error.statusCode}: ${error.message}`;
}
if (error instanceof NetworkError) {
// error: NetworkError — accès à isOffline OK
return error.isOffline
? 'Pas de connexion internet'
: `Erreur réseau: ${error.message}`;
}
if (error instanceof Error) {
// error: Error (générique)
return `Erreur inattendue: ${error.message}`;
}
// error: unknown (aucune classe connue)
return 'Erreur inconnue';
}
// Utilisation avec les classes natives
function processInput(input: string | Date | number[]) {
if (input instanceof Date) {
return input.toLocaleDateString('fr-FR'); // input: Date
}
if (input instanceof Array) {
return input.join(', '); // input: number[]
}
return input.trim(); // input: string
}
Opérateur in — duck typing sûr
L'opérateur in vérifie si une propriété existe sur un objet. TypeScript l'utilise pour narrow les unions d'interfaces — particulièrement utile quand les types ne partagent pas de propriété discriminante ou sont des objets issus d'API externes.
interface Cat { meow(): void; indoor: boolean; }
interface Dog { bark(): void; breed: string; }
interface Fish { swim(): void; }
function makeSound(animal: Cat | Dog | Fish): void {
// Les interfaces n'ont pas de constructeur → instanceof impossible
// Utiliser 'in' pour duck typing
if ('meow' in animal) {
animal.meow(); // animal: Cat
} else if ('bark' in animal) {
animal.bark(); // animal: Dog
} else {
animal.swim(); // animal: Fish
}
}
// Cas d'usage API externe : distinguer les formes de réponse
interface ApiSuccessV1 { data: unknown; version: 1 }
interface ApiSuccessV2 { payload: unknown; metadata: object; version: 2 }
function parseApiResponse(res: ApiSuccessV1 | ApiSuccessV2) {
if ('data' in res) {
// res: ApiSuccessV1
return res.data;
}
// res: ApiSuccessV2
return res.payload;
}
'key' in obj vérifie la propriété sur l'objet ET sa chaîne de prototypes. Pour les objets littéraux TypeScript c'est toujours correct. hasOwnProperty est plus strict mais n'est pas reconnu comme type guard par TypeScript sans déclaration explicite.
Custom Type Guards — predicate is
Quand typeof, instanceof et in ne suffisent pas, tu peux créer une fonction dont le type de retour est un type predicate : param is SomeType. TypeScript fait confiance au résultat de cette fonction pour narrow.
// Interfaces d'une API REST
interface User { id: number; name: string; email: string; }
interface Product { id: number; name: string; price: number; sku: string; }
interface Order { id: number; userId: number; items: Product[]; total: number; }
// Type guard pour valider la structure d'un objet API (runtime safety)
function isUser(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
typeof (value as Record<string, unknown>).id === 'number' &&
typeof (value as Record<string, unknown>).name === 'string' &&
typeof (value as Record<string, unknown>).email === 'string'
);
}
function isProduct(value: unknown): value is Product {
return (
typeof value === 'object' &&
value !== null &&
typeof (value as Record<string, unknown>).price === 'number' &&
typeof (value as Record<string, unknown>).sku === 'string'
);
}
// Utilisation pour valider les données API
async function fetchEntity(id: number, type: 'user' | 'product') {
const response = await fetch(`/api/${type}/${id}`);
const data: unknown = await response.json();
if (type === 'user' && isUser(data)) {
// data: User — accès sûr à toutes les propriétés
return `${data.name} (${data.email})`;
}
if (type === 'product' && isProduct(data)) {
// data: Product
return `${data.name} - ${data.price}€`;
}
throw new Error('Format de réponse inattendu');
}
// Type guard avec Array.filter — syntaxe courante Angular
const items: (User | Product | null)[] = await loadItems();
// Sans type guard : filter retourne (User | Product | null)[]
const withNulls = items.filter(item => item !== null);
// Avec type guard : filter retourne (User | Product)[]
function isNotNull<T>(value: T | null | undefined): value is T {
return value !== null && value !== undefined;
}
const withoutNulls = items.filter(isNotNull);
// withoutNulls: (User | Product)[] — null éliminé du type
Discriminated Unions — pattern clé
Les discriminated unions (unions discriminées) sont le pattern TypeScript le plus puissant pour modéliser des états exclusifs. Chaque membre de l'union partage une propriété discriminante (souvent type, kind ou status) avec une valeur littérale unique.
// Modéliser les états d'une requête async — pattern courant Angular
type ApiState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T; timestamp: number }
| { status: 'error'; error: Error; retryCount: number };
// TypeScript narrow automatiquement dans le switch
function renderState<T>(state: ApiState<T>): string {
switch (state.status) {
case 'idle':
return 'En attente...';
case 'loading':
return 'Chargement...';
case 'success':
// state: { status: 'success'; data: T; timestamp: number }
const ago = Date.now() - state.timestamp;
return `${JSON.stringify(state.data)} (il y a ${Math.round(ago / 1000)}s)`;
case 'error':
// state: { status: 'error'; error: Error; retryCount: number }
return `Erreur: ${state.error.message} (tentative ${state.retryCount})`;
}
}
// Usage avec Angular Signals
import { signal, computed } from '@angular/core';
const usersState = signal<ApiState<User[]>>({ status: 'idle' });
// computed() réagit aux changements du signal
const userCount = computed(() => {
const state = usersState();
if (state.status === 'success') {
return state.data.length; // state.data: User[] — narrowed
}
return 0;
});
// Dans le service
async function loadUsers() {
usersState.set({ status: 'loading' });
try {
const data = await fetchUsers();
usersState.set({ status: 'success', data, timestamp: Date.now() });
} catch (error) {
usersState.set({
status: 'error',
error: error instanceof Error ? error : new Error(String(error)),
retryCount: 1
});
}
}
Assertion Functions
Une assertion function lève une erreur si la condition n'est pas vraie, et TypeScript narrow le type après l'appel. La signature utilise asserts param is Type (TypeScript 3.7+).
// Assertion function : lève une erreur si la condition échoue
function assertIsString(value: unknown): asserts value is string {
if (typeof value !== 'string') {
throw new TypeError(`Expected string, got ${typeof value}`);
}
// Si on arrive ici sans exception → value est string
}
function assertDefined<T>(value: T | null | undefined): asserts value is T {
if (value == null) {
throw new Error('Expected defined value, got null/undefined');
}
}
// Après l'appel, TypeScript narrow automatiquement
function processConfig(config: unknown) {
assertIsString((config as Record<string, unknown>).apiUrl);
// Après cette ligne, TypeScript sait que config.apiUrl est string
// (si l'assertion lève une erreur, la ligne suivante n'est jamais atteinte)
const url = (config as Record<string, unknown>).apiUrl;
// url: string — TypeScript fait confiance à l'assertion
}
// Cas d'usage Angular : asserter les @Input() requis
@Component({ selector: 'app-card', template: '...' })
export class CardComponent implements OnInit {
@Input() userId!: number | null; // peut arriver null si mal configuré
ngOnInit() {
assertDefined(this.userId);
// Après cette ligne: this.userId est number dans ngOnInit
this.loadUser(this.userId); // pas de warning TypeScript
}
private loadUser(id: number) { /* ... */ }
}
never et exhaustiveness checking
Le type never représente une valeur qui ne peut jamais exister. Dans un switch exhaustif sur une union discriminée, TypeScript réduit le type à never dans le cas default. Si tu ajoutes un nouveau membre à l'union sans mettre à jour le switch, TypeScript génère une erreur à la compilation.
// Union avec 3 états
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'rectangle'; width: number; height: number }
| { kind: 'triangle'; base: number; height: number };
// Fonction auxiliaire : si appelée avec une valeur, TypeScript signale une erreur
function assertNever(value: never): never {
throw new Error(`Unhandled case: ${JSON.stringify(value)}`);
}
function calculateArea(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'rectangle':
return shape.width * shape.height;
case 'triangle':
return (shape.base * shape.height) / 2;
default:
// Si tous les cas sont couverts → shape est never ici → OK
// Si on ajoute 'pentagon' à Shape sans l'ajouter ici →
// TypeScript: "Argument of type 'Pentagon' is not assignable to 'never'"
return assertNever(shape);
}
}
// Test : ajout d'un nouveau membre
type ShapeV2 =
| Shape
| { kind: 'pentagon'; side: number }; // Nouveau membre
// calculateArea avec ShapeV2 → ERREUR IMMÉDIATE dans l'IDE
// TypeScript signale que 'pentagon' n'est pas géré dans le switch
// → aucun bug de runtime possible, la compilation est ton filet de sécurité
Patterns Angular — Signals et HTTP
Les type guards et le narrowing s'intègrent naturellement dans les patterns Angular modernes : réponses HTTP typées, Signals avec états complexes, et directives structurelles.
Service HTTP avec validation des réponses
// Guard pour valider les réponses de l'API au runtime
interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
pageSize: number;
}
function isPaginatedResponse<T>(
value: unknown,
isItem: (x: unknown) => x is T
): value is PaginatedResponse<T> {
return (
typeof value === 'object' &&
value !== null &&
Array.isArray((value as Record<string, unknown>).items) &&
(value as Record<string, unknown>).items.every(isItem) &&
typeof (value as Record<string, unknown>).total === 'number'
);
}
function isUser(x: unknown): x is User {
return typeof x === 'object' && x !== null &&
typeof (x as Record<string, unknown>).id === 'number' &&
typeof (x as Record<string, unknown>).email === 'string';
}
@Injectable({ providedIn: 'root' })
export class UserService {
private http = inject(HttpClient);
getUsers(): Observable<User[]> {
return this.http.get('/api/users').pipe(
map((response) => {
if (isPaginatedResponse(response, isUser)) {
return response.items; // TypeScript: items est User[]
}
throw new Error('Format de réponse inattendu');
})
);
}
}
Narrowing dans les templates Angular
<!-- Angular @if narrow le type dans le template -->
@if (user()) {
<!-- user() est truthy → TypeScript sait que user() n'est pas null/undefined -->
<span>{{ user()!.name }}</span> <!-- Le ! est parfois nécessaire selon la config strictNullChecks -->
}
<!-- Pattern avec discriminated union dans le template -->
@switch (apiState().status) {
@case ('loading') {
<div class="spinner"></div>
}
@case ('success') {
<!-- apiState() est narrowed à ApiState avec status='success' -->
<!-- Mais les templates Angular ne narrowent pas automatiquement -->
<!-- → utiliser un getter computed() TypeScript pour exposer les données -->
<data-table [rows]="successData()" />
}
@case ('error') {
<div class="alert">{{ errorMessage() }}</div>
}
}
Pièges courants à éviter
1. Custom guard qui ment à TypeScript
// DANGEREUX : le guard dit "c'est un User" mais ne vérifie pas tout
function isUser(value: unknown): value is User {
return typeof value === 'object' && value !== null;
// Oubli : pas de vérification de id, name, email !
// TypeScript te fait confiance mais la donnée sera peut-être invalide au runtime
}
// CORRECT : vérifier toutes les propriétés requises
function isUser(value: unknown): value is User {
if (typeof value !== 'object' || value === null) return false;
const v = value as Record<string, unknown>;
return typeof v.id === 'number' &&
typeof v.name === 'string' &&
typeof v.email === 'string';
}
2. Narrowing perdu après une callback async
// TypeScript narrow correctement dans la fonction synchrone
function process(x: string | null) {
if (x !== null) {
// x: string ici
setTimeout(() => {
// ATTENTION: x est toujours string | null ici dans TypeScript strict
// (certaines versions narrow dans les callbacks, d'autres non)
console.log(x.toUpperCase()); // peut générer un warning
}, 0);
// Bonne pratique : capturer dans une variable const
const safeX = x; // TypeScript sait que safeX ne peut pas redevenir null
setTimeout(() => {
console.log(safeX.toUpperCase()); // OK, safeX: string
}, 0);
}
}
3. as au lieu d'un guard
// MAUVAIS : cast forcé — aucune vérification runtime
const user = response as User;
console.log(user.email); // peut crasher si response n'est pas un User
// BON : type guard avec vérification runtime
if (isUser(response)) {
console.log(response.email); // sûr car vérifié
} else {
throw new Error('Invalid response format');
}
typeof pour les primitives, instanceof pour les classes, in pour les interfaces simples, un custom guard pour les objets d'API, et les discriminated unions pour les états applicatifs. never pour l'exhaustivité des switches critiques.
Assertion functions — narrowing par throw
TypeScript 3.7+ introduit les assertion functions : des fonctions qui throw si la condition échoue et narrow le type APRÈS l'appel. Plus concis qu'un if + early return pour les validations critiques.
// Signature avec "asserts" au lieu de retour bool
function assertDefined<T>(value: T | null | undefined, name: string): asserts value is T {
if (value === null || value === undefined) {
throw new Error(`${name} is required but was ${value}`);
}
}
function assertString(value: unknown): asserts value is string {
if (typeof value !== 'string') {
throw new TypeError(`Expected string, got ${typeof value}`);
}
}
// Usage — narrowing direct sans bloc if
function processUser(input: User | null) {
assertDefined(input, 'input');
// À partir d'ici, input est typé User (non-null)
console.log(input.name.toUpperCase());
}
function parseId(raw: unknown): string {
assertString(raw);
return raw.trim().toLowerCase();
}
Les assertion functions sont idéales pour les preconditions en début de fonction. Combinées à throw new TypeError, elles produisent des messages d'erreur clairs et précis. Attention : si vous appelez une assertion function à travers une référence variable (const fn = assertDefined), TypeScript ne peut plus narrow — le pattern doit être un appel direct.
Validation runtime avec Zod — typage + parsing en une étape
Pour les données qui viennent de l'extérieur (API, localStorage, formulaires, query string), un type guard manuel est fragile : il faut vérifier chaque propriété, gérer les types optionnels, valider les contraintes (longueur, regex, range). Zod automatise tout ça avec inférence TypeScript intégrée.
import { z } from 'zod';
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
age: z.number().int().min(0).max(120),
role: z.enum(['admin', 'user', 'guest']),
profile: z.object({
bio: z.string().max(500).optional(),
avatar: z.string().url().nullable(),
}),
createdAt: z.coerce.date(), // accepte string ISO, converti en Date
});
// Type inféré automatiquement
type User = z.infer<typeof UserSchema>;
// { id: string; email: string; age: number; role: 'admin' | 'user' | 'guest';
// profile: { bio?: string; avatar: string | null }; createdAt: Date }
// Parsing strict — throw en cas d'erreur
const user = UserSchema.parse(unknownData);
// Parsing safe — retourne discriminated union
const result = UserSchema.safeParse(unknownData);
if (result.success) {
console.log(result.data.email); // typé string
} else {
console.error(result.error.flatten()); // erreurs détaillées par champ
}
Pattern : custom type guard avec generic
// Type guard générique réutilisable pour vérifier la présence d'une propriété
function hasProperty<K extends string>(
obj: unknown,
key: K
): obj is Record<K, unknown> {
return typeof obj === 'object' && obj !== null && key in obj;
}
const data: unknown = await fetchData();
if (hasProperty(data, 'user') && hasProperty(data.user, 'email')) {
console.log(data.user.email); // typé unknown mais accessible
}
Valibot — l'alternative ultra-légère
Valibot (2024) propose la même API conceptuelle que Zod avec un bundle 4-5x plus petit grâce au tree-shaking. Utile sur les apps client où chaque KB compte (PWA mobile, e-commerce, dashboards). API similaire :
import { object, string, number, parse } from 'valibot';
const UserSchema = object({
id: string(),
age: number(),
});
const user = parse(UserSchema, unknownData); // throw si invalide
Quand préférer Zod, quand préférer Valibot
- Zod — backend Node.js, monorepos, projets où le bundle size importe peu. Écosystème mature (tRPC, react-hook-form, conform).
- Valibot — apps client critiques sur le poids, mobile, fonctions edge où le cold-start compte.
- Pas de validation runtime — type guard manuel ou assertion function suffisent pour des données internes contrôlées.
Narrowing avancé — control flow analysis
TypeScript pratique le control flow analysis : il suit l'exécution du code et narrow les types aux endroits exacts où le narrowing est possible. Quelques patterns peu connus mais puissants :
Narrowing avec const et variables intermédiaires
type Result = { ok: true; value: string } | { ok: false; error: string };
function process(result: Result) {
// ❌ Avant TS 4.4 — narrowing perdu via la variable
const isOk = result.ok;
if (isOk) {
// result.value n'est PAS accessible (TS < 4.4)
}
// ✅ TS 4.4+ — narrowing préservé via const aliasing
const ok = result.ok;
if (ok) {
result.value; // typé string
}
}
Narrowing dans les callbacks avec satisfies
// satisfies préserve le type littéral exact
const STATUS_COLORS = {
idle: 'gray',
loading: 'blue',
error: 'red',
success: 'green',
} satisfies Record<string, string>;
// Le type retient les clés littérales — useful avec un Status union
type Status = keyof typeof STATUS_COLORS; // 'idle' | 'loading' | 'error' | 'success'
function getColor(s: Status): string {
return STATUS_COLORS[s]; // type-safe, narrowing automatique
}
Narrowing avec NonNullable et exhaustive switch
function ensureNonNull<T>(x: T): NonNullable<T> {
if (x === null || x === undefined) {
throw new Error('Value is null or undefined');
}
return x as NonNullable<T>;
}
type Action = { type: 'ADD' } | { type: 'REMOVE' } | { type: 'UPDATE' };
function handle(action: Action) {
switch (action.type) {
case 'ADD': return /* ... */;
case 'REMOVE': return /* ... */;
case 'UPDATE': return /* ... */;
default:
// Sécurité d'exhaustivité — si on ajoute un case, erreur TS ici
const _exhaustive: never = action;
throw new Error(`Unhandled action: ${(action as any).type}`);
}
}
Mini-projet appliqué — couche de validation API end-to-end
Pour montrer comment tous les patterns de narrowing s'imbriquent en production, voici une couche de validation complète pour une API : réception d'un payload, validation Zod, narrowing discriminé, gestion d'erreur typée, et retour d'un Result<T, E> sûr. C'est le squelette qu'on retrouve dans toutes les API tRPC, NestJS strict, ou backend Fastify typé.
1. Type Result<T, E> — discriminated union pour les erreurs
Plutôt que try/catch partout, on encode succès et échec dans le type retourné. Cela force le consommateur à narrowmer avant d'accéder à la valeur. Pour comprendre la structure des discriminated unions, voir le guide type vs interface.
// Result discriminé — succès vs échec
type Result<T, E = string> =
| { ok: true; value: T }
| { ok: false; error: E };
// Erreurs typées par catégorie
type ApiError =
| { kind: 'validation'; issues: { path: string; message: string }[] }
| { kind: 'not_found'; resource: string; id: string }
| { kind: 'forbidden'; reason: string }
| { kind: 'network'; status: number; message: string };
type ApiResult<T> = Result<T, ApiError>;
2. Schéma Zod + type inféré (single source of truth)
import { z } from 'zod';
const CreateOrderSchema = z.object({
customerId: z.string().uuid(),
items: z.array(z.object({
productId: z.string().uuid(),
quantity: z.number().int().min(1).max(99),
})).min(1, 'Order must contain at least 1 item'),
shippingAddress: z.object({
street: z.string().min(3),
city: z.string().min(2),
zip: z.string().regex(/^\d{5}$/, 'Invalid French ZIP code'),
country: z.literal('FR'),
}),
notes: z.string().max(500).optional(),
});
type CreateOrderInput = z.infer<typeof CreateOrderSchema>;
3. Fonction de validation — type predicate vs assertion
Deux variantes selon le style préféré. Pour rappeler la différence entre is et asserts, voir les sections précédentes sur les type guards custom et assertion functions.
// Variante 1 : type predicate retournant Result<T, E>
function validateOrder(input: unknown): ApiResult<CreateOrderInput> {
const parsed = CreateOrderSchema.safeParse(input);
if (!parsed.success) {
return {
ok: false,
error: {
kind: 'validation',
issues: parsed.error.issues.map(i => ({
path: i.path.join('.'),
message: i.message,
})),
},
};
}
return { ok: true, value: parsed.data };
}
// Variante 2 : assertion function (throw)
function assertOrder(input: unknown): asserts input is CreateOrderInput {
const parsed = CreateOrderSchema.safeParse(input);
if (!parsed.success) {
const issues = parsed.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join(', ');
throw new ValidationError(`Invalid order: ${issues}`);
}
}
4. Service métier — exhaustiveness checking sur les erreurs
async function createOrder(rawInput: unknown): Promise<ApiResult<Order>> {
const validation = validateOrder(rawInput);
if (!validation.ok) return validation; // forward l'erreur validation
const input = validation.value; // typé CreateOrderInput grâce au narrowing
// Vérification métier supplémentaire
const customer = await db.customer.findById(input.customerId);
if (!customer) {
return {
ok: false,
error: { kind: 'not_found', resource: 'customer', id: input.customerId },
};
}
if (customer.blocked) {
return {
ok: false,
error: { kind: 'forbidden', reason: 'customer_blocked' },
};
}
const order = await db.order.create({ ...input, status: 'pending' });
return { ok: true, value: order };
}
5. Couche HTTP — narrowing exhaustif sur le type d'erreur
// Handler Fastify / Express — narrowing complet par kind
async function postOrderHandler(req: Request, res: Response) {
const result = await createOrder(req.body);
if (result.ok) {
return res.status(201).json(result.value);
}
// Narrowing exhaustif — TypeScript force à traiter chaque kind
const error = result.error;
switch (error.kind) {
case 'validation':
return res.status(400).json({ errors: error.issues });
case 'not_found':
return res.status(404).json({ message: `${error.resource} ${error.id} not found` });
case 'forbidden':
return res.status(403).json({ message: error.reason });
case 'network':
return res.status(502).json({ message: error.message, code: error.status });
default:
// Sécurité de compilation : si on ajoute un nouveau kind, TS échoue ici
const _exhaustive: never = error;
return res.status(500).json({ message: 'Unknown error', _exhaustive });
}
}
ApiError provoquent une erreur de compilation dans tous les handlers, forçant le traitement explicite. Plus de "j'ai oublié de gérer le 403".
6. Côté frontend — consommation typée du Result
Le client TypeScript bénéficie du même typage. Pour aller plus loin sur les patterns React/Angular consommant des APIs, lire le guide générique safeFetch<T> qui complète ce pattern.
// Consommation côté client — narrowing identique
async function placeOrder(input: CreateOrderInput): Promise<void> {
const res = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
const data = await res.json() as ApiResult<Order>;
if (!data.ok) {
// Narrowing sur error.kind pour afficher la bonne UI
if (data.error.kind === 'validation') {
displayFormErrors(data.error.issues);
} else if (data.error.kind === 'forbidden') {
redirectToContactPage();
} else {
showGenericError(data.error);
}
return;
}
// data.value est typé Order ici
redirectToOrderConfirmation(data.value.id);
}
Pour pousser le pattern Result jusqu'à l'élimination totale des exceptions, lire également le guide des utility types (sections branded types et ApiResult<T, E>) et les decorators NestJS qui automatisent la validation Zod en amont de chaque endpoint.