Where We Stopped Last Time
Jumping in mid-series? Part 1 lays the groundwork that this installment builds on.
The first part delivered a login form powered end-to-end by Zod as the single authority:
- Schemas (
LoginCredentialsSchema,BasicPasswordSchema) that enforce the same rules on client and server - A NestJS pipeline built around
ZodValidationPipeand theZodBodydecorator - Angular's signal-era
form()API combined withvalidateStandardSchema, letting the template render Zod-issued messages
That solution functioned, yet there was a persistent shortcoming: every message was baked straight into the schema's message property.
ctx.addIssue({
code: 'custom',
message: 'Password must contain at least one number',
});
For a demonstration, that approach suffices — but it collapses as soon as translation enters the picture.
Why not simply translate that message text? The answer rests on separation of responsibilities. Because the schema sits inside shared code, the backend also inherits it. And the backend has zero need for localization — its responses target machines and other services, not eyeballs, so English as the default is acceptable. Turning errors into proper user-facing phrases is strictly a frontend concern. Weaving translation into message would smear a UI-specific issue across a module the backend depends on.
That rules out message. However, Zod gives each issue another property: code, meant to describe exactly the flavor of failure. Yet two hurdles block using it directly:
- For bespoke validators,
codeis forevercustom— hardly meaningful. - The old Angular validator mapping misses some common cases — take
required, for instance. Zod'smin()emits acodeoftoo_small, which is opaque and confusing. For smooth DX, something more descriptive and stable is preferrable to staring attoo_smallorcustom.
So the solution is not translating messages at all. Instead, we need a bit of metadata the API can safely discard while the frontend uses it to build the appropriate localized string — a label for which rule failed, independent of how to phrase it.
Phase 1: Error Codes
The general idea is straightforward: attach extra metadata to each Zod issue so errors can be identified without parsing text.
But before the schemas are touched, a file must define machine-readable codes. Reusability is the only goal — nothing sophisticated.
/**
* Standardized error codes for validation errors.
*/
export const errorCodes = {
need_number: 'need_number',
need_letter: 'need_letter',
};
The existing BasicPasswordSchema now needs updating to adopt these codes. The custom validator skeleton is already in place — one more property is required. We'll label it errorCode.
//password.schemas.ts
import { z } from 'zod';
import { errorCodes } from './error-codes'; //<---- import the new error codes
const hasNumber = (value: string): boolean => /\d/.test(value);
const hasLetter = (value: string): boolean => /[a-zA-Z]/.test(value);
export const BasicPasswordSchema = z
.string()
.min(5)
.superRefine((value, ctx) => {
if (!hasNumber(value)) {
ctx.addIssue({
code: 'custom',
message: 'Password must contain at least one number',
errorCode: errorCodes.need_number, // <---- add the error code here
});
}
if (!hasLetter(value)) {
ctx.addIssue({
code: 'custom',
message: 'Password must contain at least one letter',
errorCode: errorCodes.need_letter, // <---- add the error code here
});
}
});
Two observations worth making:
messagestays behind. It isn't being deleted. The backend (for logs, Swagger output, etc.) still has a use for a clear English phrase. We've simply stopped depending on it for the UI.errorCodeis an addition. The nativecodefield remains in effect, serving as a backup.
Phase 2: Converting Issues with Transformer Functions
Our custom validators now carry a dependable errorCode. Next, something must turn a raw Zod issue into a structure the i18n system understands. This is exactly what the errorTransformer functions are designed for.
First, the target shape needs defining.
The following file lands in our NX repo's shared validation library.
//shared/validation/src/lib/error-transformer.ts
export interface TranslocoToken {
key: string; // ex. "login.email.invalid_format"
params: Record<string, unknown>; // ex. { format: "email", pattern: "..." }
}
A TranslocoToken is simply a translation key bundled with any dynamic data that key requires (for instance: "Password must be at least {minLength} characters"). This constitutes the bridge between "Zod validation stoped" and "Transloco, render something." The params property is typed Record<string, unknown> deliberately — we don't know these values in advance, but typing them as unknown pushes consumers to perform proper narrowing.
This design avoids mapping layers. Too many "single source of truth" projects quietly reintroduce duplication through DTO mappers — each schema field ends up tracked in at least one extra location. The approach here keeps every change rooted in a single place: edit the schema, and there's nothing else to sync. No mappers, no lingering maintenance burden.
Describing a Zod issue
Prior to implementing transformZodIssue, the input type requires definition. "A Zod issue" isn't one uniform item:
- It could genuinely be a Zod issue — the native
$ZodIssueBase, augmented with theerrorCodefrom Phase 1. - Alternatively, it might merely mimic an issue — structurally close (
code,path,message), but born outside Zod itself.
Therefore, both shapes are modeled. Since neither path is ever parsed at runtime here — no incoming data calls .parse() — plain TypeScript types offer sufficient strictness; wheeling in another Zod schema would be overhead with zero practical gain:
//shared/validation/src/lib/error-transformer.ts
import { z } from 'zod';
type PathSegment = PropertyKey | { key: PropertyKey };
function normalizePathSegment(segment: PathSegment): PropertyKey {
return typeof segment === 'object' ? segment.key : segment;
}
export interface LooseIssue {
code?: string;
errorCode?: string;
path?: PathSegment[];
message?: string;
}
export type IssueLike =
| (z.core.$ZodIssueBase & { errorCode?: string })
| LooseIssue;
A few points worth highlighting:
PathSegmentfollows the Standard Schema spec, not merely Zod's convention. The spec allows a segment as plainPropertyKeyor as an object like{ key: PropertyKey }. Zod produces only the plain variant, butIssueLikeisn't restricted to Zod output — it describes any Standard-Schema-compliant issue. So both forms appear in the type, andnormalizePathSegmentserves as the solitary translator that reduces either form back toPropertyKey.LooseIssueis a plain interface — not a Zod schema. It handles the odd non-Zod scenario (typically the backend error shape). Four optional fields, no validation logic behind them.IssueLikeis a union, not a single interface. Native Zod issues carry$ZodIssueBaseguarantees; anything else lands inLooseIssue.transformZodIssuedoesn't discriminate between them — both share the same surface:code,errorCode,path,message.
Inside transformZodIssue: take one issue, return one token
//shared/validation/src/lib/error-transformer.ts
export function transformZodIssue(
issue: IssueLike,
formNamespace: string,
): TranslocoToken {
const path = issue.path ?? [];
const pathKey =
path.length > 0 ? path.map(normalizePathSegment).join('.') : 'global';
const finalErrorCode = issue.errorCode ?? issue.code ?? 'unknown_error';
const key = `${formNamespace}.${pathKey}.${finalErrorCode}`;
const ignoredKeys = new Set<string>([
'code',
'path',
'message',
'origin',
'errorCode',
]);
const restParams = Object.entries(issue).reduce<Record<string, unknown>>(
(acc, [currentKey, value]) => {
if (!ignoredKeys.has(currentKey)) {
acc[currentKey] = value;
}
return acc;
},
{},
);
return {
key,
params: restParams,
};
}
Breaking down the design:
- the translation key relies on three pieces: the form's namespace, the offending field's path, and the error code. This ensures each potential validation failure in our app gets a one-of-a-kind key.
formNamespacedenotes the form itself (likeloginorregister).pathKeygets the path to the failed field (likeemailorpassword). AndfinalErrorCodecomes straight from the schema's definitions (such asneed_numberorneed_letter). pathundergoesnormalizePathSegmentprior to joining into a string. This way, whether the source usesPropertyKeyor the{ key: PropertyKey }object form is irrelevant.finalErrorCodealways has a fallback ready. Thus, standard Zod validators and their default codes remain usable.restParamsacts as a channel for any supplementary Zod issue data traveling to the translation layer. Think minimum/maximum lengths, regex strings, etc. Keys already reflected in the key-building or params are stripped viaignoredKeys, now aSetinstead of an array — equivalent behavior, cheaper membership lookups.ignoredKeys: thecode,errorCode, andpathfields are already consumed elsewhere; no duplicate values in params.- The
messagefield is explicitly dropped from params. It carries no value for translation, and there's no reason to expose it to the client.
Moving on to the full issues array — the collection Zod returns on validation failure.
//shared/validation/src/lib/error-transformer.ts
export function transformZodIssues(
issues: IssueLike[],
formNamespace: string,
): TranslocoToken[] {
if (!issues || !Array.isArray(issues)) return [];
return issues.map((issue) => transformZodIssue(issue, formNamespace));
}
This wrapper is deliberately thin — mapping each issue, transforming individually. Unwrapping Angular's specific error container is outside this function's scope; that particular nightmare belongs to the framework layer, handled in Angular as you'll see next. This establishes a pristine, reusable utility: IssueLike goes in, a translatable { key, params } comes out — utter detached from any framework.
Phase 3: Wiring it up in Angular
By design, the transformers are framework-agnostic. To expose them through the template, they're wrapped inside a pair of compact pipes.
//shared/validation/src/lib/zod-transform.pipe.ts
import { Pipe, PipeTransform } from '@angular/core';
import type { ValidationError, WithFieldTree } from '@angular/forms/signals';
import {
transformZodIssue,
transformZodIssues,
type IssueLike,
} from './error-transformer';
function toIssueLike(error: WithFieldTree<ValidationError>): IssueLike {
if ('issue' in error && error.issue) {
return error.issue as IssueLike;
}
return {
errorCode: error.kind,
message: error.message,
path: [],
};
}
@Pipe({
name: 'zodTransformFirst',
standalone: true,
})
export class ZodTransformPipeFirst implements PipeTransform {
transform(
errors: WithFieldTree<ValidationError>[] | null | undefined,
namespace: string,
) {
if (!errors || errors.length === 0) return null;
return transformZodIssue(toIssueLike(errors[0]), namespace);
}
}
@Pipe({
name: 'zodTransformAll',
standalone: true,
})
export class ZodTransformPipeAll implements PipeTransform {
transform(
errors: WithFieldTree<ValidationError>[] | null | undefined,
namespace: string,
) {
if (!errors || errors.length === 0) return null;
const issues = errors.map(toIssueLike);
return transformZodIssues(issues, namespace);
}
}
Our shared validation library houses this file at zod-transform.pipe.ts.
toIssueLike acts as the translator between Angular and transformation logic. The Signal Forms errors() iterator outputs a hybrid of two sources: status from Zod checkers (which expose an .issue field) and natively-mapped Signal Forms validators (like required or minlength) which bypass Zod and lack any .issue at all. toIssueLike reconciles the mix: when .issue exists, it's passed through unaltered; otherwise, it crafts IssueLike by treating the validator's own identifier as errorCode (e.g., 'required'), guaranteeing a key for translation instead of a silent break. Defining its signature with WithFieldTree<ValidationError> and IssueLike protects that toIssueLike always yields something transformZodIssue can absorb — compile-time inspected.
A pair of pipes rather than one? The reason: in the template, often the priority is displaying only the initial error for a given field — the one the user should address first. Yet, wanting a full description of every failure is also a valid requirement.
- zodTransformFirst: This pipe fits the "single message" pattern — when space is limited, or a specific zone is reserved for notices and we aim to avoid layout jumps. Keep in mind: the arrangement of validator definitions dictates the outcome — when multiple rules trip,
errors()returns them in the order the validators ran insuperRefine. This sequencing isn't random behavior; it maps to real logic. An emptiness check (required) belongs earlier than a length check (minLength) because measuring length on an empty string is dubious. Zod merges both into one custom validator here, though keeping them conceptually — and possibly as reusable rules elsewhere — is still sound, even if ultimatelyzodTransformFirstis what presents just the first issue to the user. - zodTransformAll: Use this pipe to expose every error for a given field. Ideal for when offering holistic feedback is key, so the user realizes all facets needing correction.
- One aside: as shown, the
namespaceis passed as a pipe argument. This string identifies the form —login,register, etc. — enabling distinct translation keys for forms sharing field names.
Functionally speaking, each adapter is a conduit: the messy errors() input — whether from Zod issues or native Signal Form validation — gets normalized via toIssueLike to the transformer, yielding one or an array of TranslocoTokens.
Step 4: Connecting Everything in the Template
Once both pipes are ready, the template side turns out to be deceptively straightforward — and that’s by design. The transformer and pipes carry the entire burden; the template merely consumes the output.
Here’s what we had in the earlier installment:
@for (error of loginForm.password().errors(); track error) {
<hlm-field-error> {{ error.message }} </hlm-field-error>
}
zodTransformFirst Example:
<input
[formField]="loginForm.password"
type="password"
id="password"
hlmInput
/>
@if (loginForm.password().errors() | zodTransformFirst:'login'; as token) {
<hlm-field-error> {{ token.key | transloco }} </hlm-field-error>
}
- The
loginnamespace goes into the pipe, and the resulting value becomestoken. Look back at the transformer’s return shape —{ key, params: restParams }— and you’ll see how the empty‑password case yields a real key likelogin.password.too_small, since that validator runs first.
Password: every error visible as a checklist
<input
[formField]="loginForm.password"
type="password"
id="password"
hlmInput
/>
@if (loginForm.password().errors() | zodTransformAll:'login'; as tokens) {
@for (token of tokens; track $index) {
<hlm-field-error>
{{ token.key | transloco:token.params }}
</hlm-field-error>
}
}
Even though it renders each message and looks tidy, this approach introduces a wrinkle we hadn’t faced yet: translation key extraction. Relying on hand‑maintained keys isn’t something we want, so the natural move is Transloco’s key extractor.
Utilities such as transloco-keys-manager sweep through templates and TypeScript files for transloco pipe usages, spot string literals, and use them to generate or refresh your language JSON files. It’s a static analysis — the source is read, not executed. But our keys aren’t static here: token.key is a runtime variable rather than a literal.
A tempting shortcut that breaks down A common reflex is to spell each validator out so the keys become literals again — the way Angular’s own validators work, and something the extractor can follow without trouble.
@if (loginForm.password().errors() | zodTransformAll:'login'; as tokens) {
<hlm-field-error validator="too_small">
{{ 'login.password.too_small' | transloco: { minimum: 5 } }}
</hlm-field-error>
<hlm-field-error validator="need_number">
{{ 'login.password.need_number' | transloco }}
</hlm-field-error>
<hlm-field-error validator="need_letter">
{{ 'login.password.need_letter' | transloco }}
</hlm-field-error>
}
❌ The extractor is satisfied, but the user experience collapses: with any token present, all three messages appear at the same time, no matter which rule actually triggered. Nice DX that matches Angular habits — and it fails anyway.
Why? Look at what errors() really returns:
[
{
"kind": "standardSchema",
"issue": { "code": "too_small", "path": ["password"], ... }
},
{
"kind": "standardSchema",
"issue": { "code": "custom", "errorCode": "need_number", "path": ["password"], ... }
}
]
Each entry carries the identical kind: "standardSchema" — unlike classic Angular validators (required, minlength), which each produce their own key you can branch on, validateStandardSchema lumps everything into a single opaque kind. errorCode does reveal which rule failed at the data level, but nothing so far maps that back to something a template can structurally switch on. (This is precisely the situation toIssueLike from Step 3 targets from the other direction — a field checked with plain Angular validators instead of Zod would surface here with kind: 'required' and no .issue, which is what the pipe’s fallback branch covers.)
You can try this out yourself using the debug panel included with the demo.
The practical compromise: marker()
So the choice seems binary: dynamic and accurate, or static and extractor‑friendly. Transloco offers an escape hatch for exactly this dilemma: marker(). At runtime it does nothing — its sole purpose is to signal the extractor “treat this as a translation key,” even though the string never goes straight into the transloco pipe.
_ = [
marker('login.password.too_small'),
marker('login.password.need_number'),
marker('login.password.need_letter'),
];
Add this to the component, and the extractor adds all three keys to your language files — while the template stays exactly as the first, dynamic version.
Full demo source — including the complete Transloco setup — lives in the GitHub repository.
Part 2 Wrap‑Up
- Each custom validation rule now has a durable
errorCode, kept separate from Zod’s built‑in genericcodeand from the backend‑facingmessage. error-transformer.tsconverts any Zod issue — whether ours or Zod’s native ones — into aTranslocoToken, usingerrorCode(withcodeas fallback) to form a predictable, namespaced key. It sits atopIssueLike, a type covering both genuine Zod issues and Zod‑validatedLooseIssue, withPathSegmentSchema/normalizePathSegmentmanaging the path shape from the Standard Schema spec.- Two pipes,
zodTransformFirstandzodTransformAll, bring that transformer into templates, sized to how much feedback each field needs. A sharedtoIssueLikehelper harmonizes Zod‑originated errors and native Signal Forms validator errors (required,minlength, etc.) into oneIssueLikeform. - Since the keys are dynamic, the extractor can’t spot them on its own —
marker()seals that gap while keeping the template from falling back to a broken static version. - Multilingual messages now come straight from a translation file, with no changes to the validation schema whatsoever.
Limitations:
The standardSchema kind Angular reports for every schema‑driven error doesn’t tell you at the control level which rule failed — only errorCode does, and that’s at the data level. As a result, a thoroughly rule‑aware directive (showing or hiding per validator, not just per field) lies beyond what we’ve built here. toIssueLike does broaden the pipes to accept native Signal Forms validators alongside Zod issues, but that doesn’t alter the core constraint — it just means both error sources travel through the same, still field‑level‑only, pipeline.
