~6 min de lecture
Zod parse vs safeParse : lequel choisir en Angular
Tu as adopté Zod pour valider tes données, et maintenant tu hésites à chaque appel : parse() ou safeParse() ? Les
deux valident exactement la même chose, avec exactement le même coût. La seule différence, c'est ce qui se passe quand la
donnée est invalide. Et ce choix change radicalement la façon dont ton code Angular réagit en production.
Si Zod est encore flou pour toi, commence par les bases dans Installer et utiliser Zod en TypeScript. Ici, on rentre dans le détail de la décision.
La différence en une phrase
parse(data)retourne la donnée typée, ou lance uneZodErrorsi elle est invalide.safeParse(data)ne lance jamais. Il retourne un objet discriminé :{ success: true, data }ou{ success: false, error }.
import { z } from 'zod';
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.email(),
});
// parse : throw
const user = UserSchema.parse(payload); // ZodError si invalide
// safeParse : pas de throw
const result = UserSchema.safeParse(payload);
if (result.success) {
result.data.email; // typé, sûr
} else {
result.error.issues; // tableau des problèmes
}
Note : depuis Zod v4, z.email() remplace l'ancien z.string().email(). C'est la version utilisée dans tout le reste
de ce guide.
Pourquoi ce n'est pas qu'une histoire de style
Beaucoup de devs choisissent au hasard, puis enrobent parse() d'un try/catch partout. Résultat : du bruit, et des
exceptions qui remontent quand on oublie un seul catch. La vraie question à te poser est simple :
Est-ce que cette donnée a une chance réelle d'être invalide à cet endroit, et si oui, est-ce que je veux gérer ce cas ici plutôt que de planter ?
Si la réponse est oui, c'est safeParse. Si la donnée vient d'une source que tu contrôles déjà ou si une donnée
invalide est un vrai bug qui doit exploser au plus tôt, c'est parse.
Before / after : sortir du try/catch partout
Le réflexe le plus courant, c'est de tout faire avec parse et d'emballer chaque appel dans un try/catch. Sur un
flux RxJS, ça donne vite du code défensif illisible, et il suffit d'oublier un catch pour qu'une exception traverse
toute l'app.
// Before : parse + try/catch dispersés, type de retour flou
getUser(id: number): Observable<User | null> {
return this.#http.get<unknown>(`/api/users/${id}`).pipe(
map((body) => {
try {
return UserSchema.parse(body);
} catch (error) {
console.error('Invalid user payload', error);
return null; // le composant doit maintenant gérer le null partout
}
}),
);
}
La même intention avec safeParse rend le chemin d'erreur explicite et garde un type de retour propre :
// After : safeParse, branchement clair, pas de try/catch
getUser(id: number): Observable<User> {
return this.#http.get<unknown>(`/api/users/${id}`).pipe(
map((body) => {
const result = UserSchema.safeParse(body);
if (!result.success) {
throw new ApiContractError('GET /users', result.error);
}
return result.data;
}),
catchError((error) => this.#reportAndRethrow(error)),
);
}
Tu n'as plus de try/catch imbriqué, le success documente le cas d'échec, et le type de retour reste User au lieu
de User | null qui contaminerait tous les appelants.
Le cas concret : valider une réponse API
Avec HttpClient et safeParse
À la frontière réseau, une API peut renvoyer n'importe quoi : un champ manquant, un type qui change après un déploiement
backend, une 200 avec un corps d'erreur. Tu ne veux pas que ça crashe le flux RxJS. safeParse te laisse décider quoi
faire.
@Service()
export class UserApi {
readonly #http = inject(HttpClient);
getUser(id: number): Observable<User> {
return this.#http.get<unknown>(`/api/users/${id}`).pipe(
map((body) => {
const result = UserSchema.safeParse(body);
if (!result.success) {
throw new ApiContractError('GET /users', result.error);
}
return result.data;
}),
);
}
}
Ici tu transformes une donnée invalide en une erreur métier explicite, loguée avec le contexte de l'endpoint, au lieu de
laisser fuiter une ZodError brute dans toute l'app.
@Service() est le décorateur introduit en Angular 22 : il déclare un singleton root sans providedIn: 'root' et impose
inject() (l'injection par constructeur n'est plus possible). Sur Angular 20/21, remplace-le par
@Injectable({ providedIn: 'root' }) ; et même en v22, @Injectable reste requis dès que tu sors du singleton root
(scope non-root, providers de composant, factory custom).
Avec parse quand tu veux échouer vite
Si une réponse invalide est un contrat cassé qu'aucun composant ne saura récupérer, parse est plus direct. Inutile de
brancher sur success pour, de toute façon, relancer une erreur.
getUser(id: number): Observable<User> {
return this.#http
.get<unknown>(`/api/users/${id}`)
.pipe(map((body) => UserSchema.parse(body)));
}
La ZodError remonte dans le flux et tombe naturellement dans le error de ton subscribe ou de ton catchError
global. C'est le bon choix quand tu as déjà un handler d'erreur centralisé.
Avec httpResource (Angular v20+)
httpResource accepte une option parse appelée sur la réponse. Elle attend une fonction qui retourne la donnée
validée ou lance. C'est donc parse qu'on branche ici, pas safeParse :
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (userResource.value(); as user) {
<p>{{ user.name }} - {{ user.email }}</p>
}
@if (userResource.error()) {
<p>Données utilisateur invalides.</p>
}
`,
})
export class UserCard {
readonly id = input.required<number>();
protected readonly userResource = httpResource<User>(
() => `/api/users/${this.id()}`,
{ parse: UserSchema.parse },
);
}
Quand parse lance, la resource bascule dans error() et tu l'affiches proprement avec @if. Tu obtiens la gestion
gracieuse de safeParse sans écrire un seul try/catch, parce que c'est httpResource qui capture pour toi.
Le cas formulaire : safeParse et le mapping d'erreurs
Sur un formulaire, l'entrée est invalide par nature tant que l'utilisateur tape. Tu ne vas pas lancer une exception à
chaque frappe. safeParse est l'évidence, et Zod v4 te donne deux helpers pour transformer l'erreur en quelque chose
d'exploitable côté UI :
z.flattenError(error)renvoie{ formErrors, fieldErrors }, parfait pour mapper sur desFormControl.z.treeifyError(error)renvoie un arbre qui suit la forme de ton schéma, pratique pour les objets imbriqués.
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<form (submit)="submit($event)">
<!-- ... -->
</form>
@for (message of formErrors(); track message) {
<p class="error">{{ message }}</p>
}
`,
})
export class SignUpForm {
protected readonly formErrors = signal<readonly string[]>([]);
protected submit(event: SubmitEvent): void {
event.preventDefault();
const result = SignUpSchema.safeParse(this.#readFormValue());
if (!result.success) {
const { fieldErrors } = z.flattenError(result.error);
this.formErrors.set(Object.values(fieldErrors).flat());
return;
}
this.#register(result.data);
}
}
Petite note de migration : si tu viens de Zod v3, tu utilisais sûrement result.error.format() ou
result.error.flatten(). Ces méthodes existent toujours en v4 mais sont dépréciées au profit des fonctions
z.treeifyError et z.flattenError.
Bonus : passer par ces fonctions top-level rend le code compatible zod/mini tel quel. La version mini ne fournit pas
les méthodes de confort error.flatten() / error.format() ; z.flattenError(err) et z.treeifyError(err), elles,
fonctionnent dans les deux. Le reste (gain de bundle, API de construction des schémas) est détaillé dans
Comment j'ai réduit de 25% mon bundle Angular.
Et l'async ?
Si ton schéma contient une validation asynchrone (un refine qui attend une promesse, par exemple un check d'unicité
côté serveur), les versions synchrones lancent une erreur. Utilise alors parseAsync et safeParseAsync, qui ont
exactement la même sémantique mais retournent une promesse.
const result = await SignUpSchema.safeParseAsync(value);
Le tableau de décision
| Situation | parse | safeParse |
|---|---|---|
| Réponse API que tu veux dégrader proprement | x | |
| Réponse API avec handler d'erreur global | x | |
Option parse de httpResource |
x | |
| Entrée formulaire utilisateur | x | |
| Donnée déjà validée à la frontière, usage interne | x | |
| Tu dois brancher la logique sur la validité | x | |
| Test rapide / fixture que tu contrôles | x |
La règle qui résume tout : parse pour échouer vite sur ce qui ne devrait jamais arriver, safeParse pour gérer ce
qui peut légitimement arriver.
Les pièges à éviter
parsesur de la donnée non fiable sans handler. UneZodErrornon capturée remonte jusqu'à un gestionnaire global, ou pire, casse le flux RxJS silencieusement. Si tu n'as pas decatchError, prendssafeParse.- Oublier de tester
successavantdata. Le résultat desafeParseest une union discriminée. TypeScript t'empêche d'accéder àresult.datatant que tu n'as pas vérifiéresult.success: ne contourne pas ce garde-fou avec unas. - Valider deux fois la même donnée. parse et safeParse coûtent pareil, mais valider en boucle sur chaque
changement de signal reste du gaspillage. Valide à la frontière, garde le type inféré ensuite. Si tu hésites encore
entre
any,unknownet Zod pour cette frontière, le détail est dans Arrête d'utiliser any en TypeScript. - Embarquer tout Zod pour trois schémas. Sur un bundle Angular surveillé, regarde
zod/mini: même API pour les cas courants, poids réduit. On l'explique dans Comment j'ai réduit de 25% mon bundle Angular.
À retenir
parse et safeParse valident la même chose. Ce que tu choisis, c'est la stratégie d'erreur : exception qui remonte,
ou résultat que tu inspectes. En Angular, ça se traduit simplement : safeParse aux endroits où l'invalide est attendu
(formulaires, API que tu veux dégrader), parse là où l'invalide est un bug (contrat strict, option parse de
httpResource, code interne déjà validé).
Envie de mettre tout ça en pratique sur une vraie archi Angular, de la couche infrastructure aux formulaires ? Découvre les modules d'EasyAngularKit.