Recap: Where We Left Off
New here? This is Part 2 of a series — Part 1 covers the initial Zod + Angular + NestJS setup this recap builds on.
In Part 1, we built a full-stack login form using Zod as a single source of truth…
- Zod schemas (
LoginCredentialsSchema,BasicPasswordSchema) driving both frontend and backend validation - A custom
ZodValidationPipeandZodBodydecorator on the NestJS side - Angular’s new signal-based
form()API wired up withvalidateStandardSchema, rendering Zod errors directly in the template
It worked — but with one nagging limitation: our error messages were hardcoded directly into the schema.
ctx.addIssue({
code: 'custom',
message: 'Password must contain at least one number',
});
That’s fine for a demo, but it falls apart the moment you need multiple languages.
You might wonder — why not just keep using message and translate that string? The answer comes down to separation of concerns. The schema is shared with the backend, and the backend doesn’t care about translation — its API errors are consumed by clients (or other services), not rendered directly to end users, so there’s no reason for it to carry multilingual text. English (or whatever the API’s internal convention is) is perfectly fine there. Displaying a translated, user-facing message is entirely the frontend’s job. If we tie the schema’s message to translation, we’d be leaking a frontend concern into a piece of code the backend also depends on.
Okay, so message doesn’t work, but the Zod issues object has another field: code. This is a stable identifier for the type of error that occurred. But there are 2 problems with using code directly:
- With custom validators the
codeis alwayscustom, so thats not very informative. - Some of the validators not projected to the old angular validators, like
required. The validator which we can use in zodmin()provides acodeoftoo_smallwhich is very confusing. To keep our developer experience smooth, we want something more descriptive and stable thantoo_smallorcustom.
So instead of translating the message itself, we need something the backend can happily ignore and the frontend can turn into a proper, localized string: a stable identifier for what went wrong, not how to say it.
Step 1: Introducing Error Codes
The core idea is simple: add some extra metadata to our Zod issues, so we can identify the errors without relying on the message text.
Before updating the schemas, we first need to create a file with machine-readable error codes. The main purpose here is reusability, nothing fancy.
/**
* Standardized error codes for validation errors.
*/
export const errorCodes = {
need_number: 'need_number',
need_letter: 'need_letter',
};
Now we need to update our Zod schema: BasicPasswordSchema to use these error codes. We already have the custom validator skeleton, we just need to add another bone to it. Let’s call 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
});
}
});
A couple of things to note here:
- message stays. We are not removing it. The backend (logs, swagger, etc) still can make a good use of a readable English message. We are just not relying on it for the frontend anymore.
- errorCode is additive. We are not replacing the
codefield, we still using it as a fallback option.
Step 2: Handling errorCode with transformer functions
Now that our custom validators can carry a stable errorCode, we need something that turns a raw Zod issue into a shape which our i18n layer can actually use. This is where the errorTransformer functions come in.
First, let’s define what we are transforming into.
We are adding this file to our shared validation library in our NX repo.
//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 just a translation key plus whatever dynamic values that translation might need (think: “Password must be at least {minLength} characters”). This is the contract between “Zod validation failed” and “Transloco, please render something.” params is typed as Record<string, unknown> rather than something looser — we don’t know the shape of these values ahead of time, but unknown forces whoever consumes them to narrow first.
This is a map-less architecture. A lot of “single source of truth” DTO setups end up quietly reintroducing duplication through mapper layers — every field still needs to be kept in sync in at least one extra place. Here, if a field changes on the schema, that’s the only place you touch. No mapper to update, no extra maintenance surface.
Modeling a Zod issue
Before we can write transformZodIssue, we need a type for what we’re actually receiving. “A Zod issue” isn’t one single shape here:
- It might genuinely be a Zod issue — Zod’s own
$ZodIssueBase, plus theerrorCodewe bolted on in Step 1. - Or it might be something that only resembles an issue — structurally close enough (
code,path,message), but not something Zod itself produced.
So we model both. Since neither branch is ever actually validated at runtime here — nothing calls .parse() on incoming data — plain types are enough; reaching for a Zod schema would just be ceremony with no functional payoff:
//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 couple of things worth calling out:
PathSegmentmatches the Standard Schema spec, not just Zod’s behavior. The spec allows a path segment to be a plainPropertyKeyor an object like{ key: PropertyKey }. Zod itself only ever produces the plain form, butIssueLikeis meant to cover any Standard-Schema-compliant issue, not just Zod’s — so the type includes both, andnormalizePathSegmentis the one place that collapses the distinction back down to a plainPropertyKey.LooseIssueis a plain interface, not a Zod schema. It covers the rare, non-Zod case — mainly a backend error shape rather than something Zod produced. Four optional fields, no validation logic behind them.IssueLikeis a union, not one interface. Real Zod issues already come with a guarantee via$ZodIssueBase; anything else falls intoLooseIssue.transformZodIssuedoesn’t need to know which branch it got — both sides exposecode,errorCode,path, andmessagethe same way.
transformZodIssue: one issue in, one token out
//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,
};
}
A few things worth unpacking:
- the key is built from 3 parts: the form namespace, the path to the field, and the error code. This gives us a unique key for every possible validation error in our app. The
formNamespaceis a string that identifies the form (ex.login,register, etc). ThepathKeyis the path to the field that failed validation (ex.email,password, etc). ThefinalErrorCodeis the error code we defined in our Zod schema (ex.need_number,need_letter, etc). pathruns throughnormalizePathSegmentbefore being joined into a string, so it doesn’t matter whether a given path segment is a plainPropertyKeyor the{ key: PropertyKey }object form the spec also allows.finalErrorCodeis a fallback chain. This way we can still use the build-in Zod validators and their codes.restParamsis a way to pass any additional data from the Zod issue to the translation layer. This is useful for things like min/max lengths, regex patterns, etc. We are ignoring the keys that we already used to build the key and params.ignoredKeysis now aSetrather than an array — same behavior, marginally cheaper membership checks.ignoredKeys: we have thecode,errorCode,pathalready, but we don’t need them in the params.- We are dropping the
messagefrom the params. The message is not needed for translation, and we don’t want to leak it to the frontend.
Now let’s handle the entire issues array, which is what we get from Zod when validation fails.
//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 is a thin wrapper — map over the issues, transform each one, done. Unwrapping Angular’s error shape isn’t this function’s job; that’s the framework’s concern, and it’s handled on the Angular side, as we’ll see next. With this in place, we now have a clean, reusable function: IssueLike in, translatable { key, params } out — completely decoupled from Angular.
Step 3: Using the transformer in Angular
The transformer functions are framework-agnostic on purpose — they don’t know anything about Angular. To actually use them in a template, we wrap them in two small 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);
}
}
This file lives at zod-transform.pipe.ts in the shared validation library.
toIssueLike is the bridge between Angular and the transformer. Signal Forms’ errors() mixes two kinds of entries: Zod-driven errors (with an .issue property) and native Signal Forms validators like required or minlength that never went through Zod and have no .issue at all. toIssueLike normalizes both: if .issue is present, forward it as-is; otherwise, fall back to treating the error’s own kind (e.g. 'required') as the errorCode, so it still produces a translatable token instead of silently falling through. Typing it against WithFieldTree<ValidationError> and IssueLike means the compiler will flag it if toIssueLike ever stops returning something transformZodIssue can accept.
Why two pipes instead of one?. Because in a template, you often only care about the first error for a field (the one the user should fix first). But sometimes you want to show all errors.
- zodTransformFirst: We can use this pipe, when we only want to show the first error. Example: our space is limited, or we have a specific area to show the errors and we don’t want to shift the UI. In this case the order of the validator definition matters — if two rules fail,
errors()returns them in the order the validators ran insidesuperRefine. This isn’t just a Zod quirk to work around; it reflects real validation logic. Arequiredcheck should logically run before something likeminLength, since a length check is meaningless on an empty value. Zod happens to fold both into the same custom validator here, but keeping them conceptually ordered — and potentially reusable as separate rules across other schemas — is still good practice, even ifzodTransformFirstis what ultimately surfaces just the first one to the user. - zodTransformAll: We can use this pipe when we want to show all errors for a field. This is useful when we want to provide comprehensive feedback to the user, ensuring they understand all the issues with their input.
- One small note: as you can see we are passing the
namespaceto the pipes. This is a string that identifies the form (ex.login,register, etc). This way we can have unique translation keys for each form, even if they have fields with the same name.
Under the hood, they’re both thin adapters: normalize whatever errors() handed us — Zod issue or native Signal Forms error — into an IssueLike via toIssueLike, then pass that to the transformer, returning a TranslocoToken (or an array of them).
Step 4: Wiring It Up in the Template
With both pipes in place, the template work is almost anticlimactic — which is exactly the point. All the complexity lives in the transformer and the pipes; the template just consumes the result.
In the previous part, we had this:
@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>
}
- we are passing the
loginnamespace to the pipe, and using the result astoken. If you look back the transformer function this was the return object:{ key, params: restParams }. This will create an actual translation key,login.password.too_small, for an empty password, because that was our first validator.
Password: all errors, 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>
}
}
This solution looks clean, and correctly shows every error. But it introduces a problem we haven’t had to think about yet: translation key extraction. We don’t like manual translation key management, so we can use Transloco’s key extractor.
Tools like transloco-keys-manager scan your templates and TypeScript files for transloco pipe usages, looking for string literals, and use them to auto-generate/update your language JSON files. It’s static analysis — it reads your source, it doesn’t run it. The problem is our keys here are dynamic: token.key is a variable, not a string literal.
A tempting but broken fix One instinct is to spell every validator out explicitly, so the keys become literals again: This is how the normal Angular validators work and the extractor can understand it.
@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>
}
❌ This makes the extractor happy, but breaks the UX: as soon as tokens is non-empty, all three messages render at once, regardless of which rule actually failed. Good DX which supports Angular habits, sadly fails.
The reason comes down to what errors() actually returns:
[
{
"kind": "standardSchema",
"issue": { "code": "too_small", "path": ["password"], ... }
},
{
"kind": "standardSchema",
"issue": { "code": "custom", "errorCode": "need_number", "path": ["password"], ... }
}
]
Every entry has the same kind: "standardSchema" — unlike Angular’s classic validators (required, minlength), where each failing rule gets its own key to branch on, validateStandardSchema funnels everything through one opaque kind. errorCode tells us which rule failed at the data level, but nothing yet bridges that back to something a template can structurally switch on. (This is exactly the case toIssueLike from Step 3 is built for the other direction — a field validated with plain Angular validators instead of Zod would show up here with kind: 'required' and no .issue at all, which is what the pipe’s fallback branch handles.)
You can experiment with this result in the attached debug panel in the demo.
The pragmatic middle ground: marker()
So: dynamic and correct, or static and extractor-friendly — pick one? Transloco has an escape hatch for exactly this tension: marker(). It’s a no-op at runtime — its only job is to tell the extractor “treat this string as a translation key,” even though it’s never actually passed to the transloco pipe directly.
_ = [
marker('login.password.too_small'),
marker('login.password.need_number'),
marker('login.password.need_letter'),
];
Drop this into the component, and the extractor adds all three keys to your language files — while the template stays exactly as it was in our first, dynamic version.
You can find the full source code of this demo, including the complete Transloco integration, in the GitHub repository.
Summarizing Part 2
- We gave every custom validation rule a stable
errorCode, separate from Zod’s own genericcodeand from the backend-facingmessage. error-transformer.tsturns any Zod issue — ours or Zod’s built-in ones — into aTranslocoToken, usingerrorCode(falling back tocode) to build a predictable, namespaced key. It’s built onIssueLike, a type that covers both a genuine Zod issue and a Zod-validatedLooseIssue, withPathSegmentSchema/normalizePathSegmenthandling the Standard Schema spec’s path shape.- Two pipes,
zodTransformFirstandzodTransformAll, expose that transformer to templates, matched to how much feedback each field actually needs. Their sharedtoIssueLikehelper bridges Zod-origin errors and native Signal Forms validator errors (required,minlength, etc.) into the sameIssueLikeshape. - Dynamic keys mean the extractor can’t see them directly —
marker()closes that gap without forcing us back to a broken, static template. - Multilingual error messages now come for free from a translation file, with zero changes to the validation schema itself.
Limitations:
standardSchema kind Angular reports for every schema-driven error doesn’t distinguish which rule failed at the control level — only errorCode does, at the data level. That means a fully rule-aware directive (show/hide per validator, not just per field) isn’t possible with what we’ve built here. toIssueLike widens the pipes to handle native Signal Forms validators alongside Zod issues, but it doesn’t change this underlying limitation — it just means both error sources now flow through the same, still field-level-only, pipeline.