Skip to content

2026-05-28

Compile-Time PII Protection: One Zod Brand for Logger, Metrics, and Spans

Bake a single PII branded type into your observability API signatures so TypeScript rejects sensitive fields at the call site, before any runtime redactor sees them.

Structured logs leak PII through three near-identical call sites: logger.info({ user }), metrics.increment("login", { email }), and tracer.startActiveSpan("auth", { attributes: { ip } }). Most teams add Pino redact.paths or a cloud-side scrubber to catch these. Those defenses are last-line and list-based, and they drift silently as schemas evolve. Effect-TS solves the same problem at runtime with Redacted<A> wired into its Logger. Pino solves a slice of it at compile time with LogFnFields, banning specific keys via never. Rust’s secrecy crate solves it by not implementing Display on the wrapper. The same compile-time idea is reachable in plain Zod and TypeScript through two parallel implementations. One is a Zod brand: lightweight and structural. The other is a wrapper class: heavier, but with runtime coverage too. Both reject PII at the observability sink. The right one depends on how much disruption your codebase can absorb.

The pattern in one philosophy

Pino’s own redaction guidance reads, in essence, that redact paths should not originate from user input and that the safer thing is not to put sensitive data in the log payload at all. Cloud-side scrubbers like Datadog Sensitive Data Scanner and AWS CloudWatch Logs Data Protection are framed the same way: useful as a backstop, dangerous as the primary control.

Compile-time prevention is the strict reading of that philosophy. If a PII-typed value cannot syntactically reach logger.info, metrics.increment, or span, then there is nothing left for the runtime layer to redact. The cost is one branded type, one helper conditional, and a handful of stricter API signatures.

One brand for all PII

The first decision is whether to wrap each field type separately (Email, IBAN, Phone) or share a single PII brand across all of them. The instinct is to reach for per-field wrappers because they read better in domain code. For the purpose of keeping values out of logs, this is wasted resolution. The logger does not care whether the offending field is an email or an IBAN. It cares whether the value is sensitive.

Sharing one brand collapses the type guard on the logger side from a union to a single check, and it removes the chore of teaching every new sensitive field to the observability layer. The brand lives in a contracts package so every service imports the same nominal identity.

// contracts/pii.ts
import { z } from "zod";

// Use Zod's native brand encoding. `string & z.$brand<"PII">` is the type
// Zod's `.brand<"PII">()` produces on inference, so the API surface and
// the schema layer agree on the exact same nominal identity.
export type PII = string & z.$brand<"PII">;

// Helper: stamps the PII brand on any string-producing Zod schema.
// `T extends z.ZodType<string>` is intentional in Zod v4; `z.email()`,
// `z.iso.date()`, and `z.uuid()` are no longer `z.ZodString` subclasses.
export const pii = <T extends z.ZodType<string>>(s: T) => s.brand<"PII">();

// Per-key rejection guard. Lives with the brand because it is part of the
// brand machinery, not the observability layer.
// Distribute over unions: V is a naked type parameter so
// RejectPII<PII | undefined> = (PII -> never) | (undefined -> undefined) = undefined.
type RejectPII<V> = V extends z.$brand<"PII"> ? never : V;
export type SafeCtx<T> = { [K in keyof T]: RejectPII<T[K]> };

The schemas for a customer-onboarding flow look like this. The fields are common to any regulated industry: email, IBAN, a national identifier, date of birth in ISO form, address.

// contracts/customer.ts
import { z } from "zod";
import { pii } from "./pii";

export const Email = pii(z.email());
// Shape check only, not full validation. Production code should pair this
// with a mod-97 checksum and country-specific length tables.
export const IBAN = pii(z.string().regex(/^[A-Z]{2}\d{2}[A-Z0-9]{1,30}$/));
export const MobileNumber = pii(z.string().regex(/^\+?[1-9]\d{1,14}$/));
export const DateOfBirth = pii(z.iso.date());
export const Address = pii(z.string().min(1).max(500));
// Shape only — production code should use the actual format for the
// jurisdiction's national identifier.
export const NationalId = pii(z.string().regex(/^\d{8,12}$/));

export const Customer = z.object({
  id: z.uuid(),
  email: Email,
  iban: IBAN,
  mobileNumber: MobileNumber,
  dateOfBirth: DateOfBirth,
  address: Address,
  nationalId: NationalId,
  marketingOptIn: z.boolean(),
});

export type Customer = z.infer<typeof Customer>;

After Customer.parse(raw), customer.email is structurally still a string at runtime; the brand exists only in the type system. The value flows through to database writes, outbound API calls, and signature generation unchanged. That is the brand’s main appeal: minimal disruption to the rest of the codebase. The compile-time gate at the observability sink is what stops the leak.

Reject PII in the observability API

The brand on its own does nothing useful at the API boundary. The leverage comes from teaching the logger, the metrics client, and the tracer that they accept structured context, but only of values that are not PII. The brand machinery lives entirely in the contracts package; the observability primitives just import SafeCtx and apply it to their context arguments.

// core/observability.ts
import pino from "pino";
import { StatsD } from "hot-shots";
import { trace, type AttributeValue } from "@opentelemetry/api";
import type { SafeCtx } from "@org/contracts";

const baseLogger = pino();
const statsd = new StatsD({ prefix: "app." });
const tracer = trace.getTracer("app");

export function makeLogger(name: string) {
  return {
    info: <T extends Record<string, unknown>>(msg: string, ctx?: SafeCtx<T>) =>
      baseLogger.info({ ...ctx, logger: name }, msg),
    warn: <T extends Record<string, unknown>>(msg: string, ctx?: SafeCtx<T>) =>
      baseLogger.warn({ ...ctx, logger: name }, msg),
    error: <T extends Record<string, unknown>>(msg: string, err?: Error, ctx?: SafeCtx<T>) =>
      baseLogger.error({ ...ctx, err, logger: name }, msg),
  };
}

export function makeMetrics(prefix: string) {
  return {
    increment: <T extends Record<string, string>>(name: string, tags?: SafeCtx<T>) =>
      statsd.increment(`${prefix}.${name}`, 1, tags),
    histogram: <T extends Record<string, string>>(
      name: string,
      value: number,
      tags?: SafeCtx<T>,
    ) => statsd.histogram(`${prefix}.${name}`, value, tags),
  };
}

export async function span<T, A extends Record<string, AttributeValue | undefined>>(
  name: string,
  attrs: SafeCtx<A>,
  fn: () => T | Promise<T>,
): Promise<T> {
  return tracer.startActiveSpan(name, { attributes: attrs }, async (s) => {
    try {
      return await fn();
    } finally {
      s.end();
    }
  });
}

The whole pattern is here. Three primitives, one shared SafeCtx<T> mapped type that walks each property and rewrites any PII-typed slot to never. The argument is generic over the caller’s literal T, which is what gives the compiler a chance to inspect each field individually. When a caller hands the logger a value whose type is PII, that key’s expected type collapses to never, the literal’s PII value is not assignable to never, and the build fails.

span returns Promise<T> even when the callback is synchronous; this is the correct trade-off for tracing, because s.end() must run after the work finishes, and most spanned work is async. The alternative would be a sync overload that does not cover the common case.

A representative call site in an onboarding handler:

const logger = makeLogger("onboarding");
const metrics = makeMetrics("onboarding");

export async function onboard(customer: Customer) {
  logger.info("onboarding.start", {
    customerId: customer.id,            // string, OK
    marketingOptIn: customer.marketingOptIn, // boolean, OK
  });

  // The following two lines do not compile.
  logger.info("onboarding.start", { email: customer.email });
  // Type 'string & $brand<"PII">' is not assignable to type 'never'.

  metrics.increment("onboarding.requested", { iban: customer.iban });
  // Type 'string & $brand<"PII">' is not assignable to type 'never'.

  return span(
    "identity.verify",
    { customerId: customer.id },        // OK
    () => verifyIdentity(customer.nationalId),
  );
}

The compiler errors are direct and read in production code without explanation. Reviewers see the line and the type system has already made the point.

How the type math works

SafeCtx<T> is a mapped type that delegates each property to RejectPII<V>. Because V is a naked type parameter inside the conditional, the conditional distributes over unions; that is what makes optional PII (PII | undefined, PII | null) fail at the call site rather than silently passing through.

// Caller writes:
logger.info("login", { email: customer.email, userId: customer.id });

// 1. TS infers T = { email: PII; userId: string }
// 2. SafeCtx<T> expands per key via RejectPII:
//    email:  PII    extends z.$brand<"PII"> ? never : PII    -> never
//    userId: string extends z.$brand<"PII"> ? never : string -> string
// 3. Argument type required: { email: never; userId: string }
// 4. Argument actually passed: { email: PII;  userId: string }
// 5. PII is not assignable to never -> error on the `email` key
//    "Type 'string & $brand<\"PII\">' is not assignable to type 'never'."

The distribution matters when the call site passes an optional PII field:

// Caller writes:
logger.info("test", { email: maybeEmail });
// where maybeEmail: PII | undefined

// 1. TS infers T = { email: PII | undefined }
// 2. SafeCtx<T> per key:
//    email: RejectPII<PII | undefined>
//         = (PII extends z.$brand<"PII"> ? never : PII)              // -> never
//         | (undefined extends z.$brand<"PII"> ? never : undefined)  // -> undefined
//         = undefined
// 3. Argument type required: { email: undefined }
// 4. Argument actually passed: { email: PII | undefined }
// 5. PII | undefined is not assignable to undefined -> error on `email`
//    "Type 'PII | undefined' is not assignable to type 'undefined'."

The mental model worth keeping is simpler than the expansion. SafeCtx rewrites every PII-typed slot to a slot that nothing useful can fill, then asks the caller to fill it anyway. The compiler refuses on a per-key basis, so the error message names the offending property by name rather than blaming a whole-object mismatch.

The flow from schema declaration to call site is one path with two outcomes. Either implementation, the brand or the wrapper class, slots into the same position in the pipeline.

Zod schemas: Email, IBAN, MobileNumber, DateOfBirth, Address, NationalId

PII branded type or wrapper class

SafeCtx T mapped type

makeLogger, makeMetrics, span signatures

call compiles: plain id, boolean, number

call rejected: PII value in context

What the brand catches

Two scenarios cover most of the day-to-day leaks the brand-only version prevents.

The direct field pass. A developer is debugging a webhook handler and reaches for the natural form: logger.info("received", { email: payload.email }). If payload came from a parsed Customer, payload.email is PII and the line does not compile. There is no Pino runtime config that needs to know about the field name in advance, no schema-to-paths walker that needs to stay in sync.

The propagated PII through a context object. Auth middlewares often build a logger context bag and thread it through downstream handlers. As soon as someone includes email or ipAddress in that bag, every downstream logger.info(msg, ctx) call fails to compile. The leak gets fixed once at the source, not at every call site that consumed the context.

For both, the failure mode is loud and local. The compile-time error names the offending property and the offending file. No runtime tracing is needed.

Because RejectPII<V> distributes over unions, optional PII fields are caught too. PII | undefined and PII | null both fail at the call site rather than slipping through under the cover of the union; the same applies to a class-instance union on the wrapper side.

What the brand still misses

The brand is structural: at runtime, a PII value is still just a string. The type system catches every named-key pass, but three escape hatches remain.

Template literals

logger.info(`User ${customer.email} logged in`);

Template interpolation evaporates the brand. The resulting string is plain string and the message string carries the raw email. The compile-time gate has no purchase here because the value never appears as a structured context property. Lint rules against template interpolation of branded values help, but the community rules for this are immature; a small custom AST rule is usually the practical answer.

JSON.stringify

logger.info("payload", { body: JSON.stringify(customer) });

JSON.stringify on a branded string returns the raw string. The body field is a plain string and the brand does not survive the serializer. Logging discrete fields rather than stringifying the whole payload is the best habit here.

any cast

logger.info("ok", { email: customer.email as any });

The cast widens PII to any, which satisfies string | number | boolean. The line compiles. This is the most explicit defeat of the pattern and the easiest to catch in review. An ESLint rule that bans as any on values whose source type is a branded type closes most of the gap; even without tooling, the cast is grep-able.

Nested objects

The SafeCtx<T> guard walks one level deep. A call that nests PII inside a plain object compiles silently:

logger.info("user.created", { user: customer });
//                            ^^^^^ this is `Customer`, not PII; the guard does not look inside.

Recursing the mapped type into nested objects is straightforward in principle but has corner cases (arrays, classes, branded objects). The escape hatch in practice is the writing discipline: do not pass parsed payloads into log context. Log discrete fields, and SafeCtx<T> catches them at the field level. The runtime redactor backstop is the right answer for the cases the type system cannot reach.

For codebases where these escape hatches are an acceptable residual risk, the brand is the right choice. For fields whose blast radius makes those hatches unacceptable, the alternative below closes them at runtime as well, at the cost of a heavier migration.

Closing the runtime hatches with a wrapper class

The wrapper class is the alternative for fields whose blast radius makes the runtime hatches unacceptable. Instead of a structural brand, PII becomes a real class whose runtime methods intercept every standard path a string takes out of an object. The schemas in contracts/customer.ts do not change; only contracts/pii.ts does.

// contracts/pii.ts — class wrapper variant for higher-sensitivity fields
import { z } from "zod";

const REDACTED = "[REDACTED]" as const;

export class PII {
  readonly #value: string;
  constructor(value: string) {
    this.#value = value;
  }
  toString(): string { return REDACTED; }
  toJSON(): string { return REDACTED; }
  [Symbol.toPrimitive](_hint: string): string { return REDACTED; }
  [Symbol.for("nodejs.util.inspect.custom")](): string { return REDACTED; }
  /** Explicit opt-out: returns the raw value. Loud, local, reviewable. */
  reveal(): string { return this.#value; }
}

export const pii = <T extends z.ZodType<string>>(s: T) =>
  s.transform((v) => new PII(v));

// SafeCtx now matches against the class instance. The naked type parameter
// in RejectPII distributes over unions, so `PII | undefined` correctly
// collapses to `undefined` and the call site fails on the PII branch.
type RejectPII<V> = V extends PII ? never : V;
export type SafeCtx<T> = { [K in keyof T]: RejectPII<T[K]> };

core/observability.ts is unchanged. It imports SafeCtx from @org/contracts exactly as before; the substitution happens entirely inside the contracts package. Every call site that compiled before still compiles, and every call site that failed before still fails. What changes is what happens to the values that escape the type system.

The trade-off is real and worth naming. With the brand, customer.email is a string at runtime and flows through to db.user.update({ email: customer.email }) or mailer.send({ to: customer.email }) unchanged. With the class, every legitimate boundary now needs customer.email.reveal(). That is a non-trivial refactor in any service with mature database, mail, and third-party integration code. The payoff is that template literals, JSON.stringify, console.log, and util.inspect all return [REDACTED] automatically. The four methods on the wrapper cover every standard path a string takes out of an object.

One sanity-check trace makes the runtime divergence visible:

info(`user ${customer.email} logged in`);
// Brand version:  compiles, message contains the raw email. The brand is gone.
// Class version:  compiles, message becomes "user [REDACTED] logged in" at
//                 runtime because the wrapper's toString returns "[REDACTED]".

The compile-time behaviour is identical between the two implementations. The runtime behaviour diverges at exactly the points where the brand falls short. Three residual risks remain on the class side: as any still defeats the type guard, .reveal() can be misused as a quiet escape hatch, and the SafeCtx<T> guard still only walks one level. A call like logger.info("user.created", { user: customer }) still compiles because the guard does not recurse into the nested Customer object. The runtime wrapper catches some of these at print time, but the writing discipline is the same: log discrete fields, not parsed payloads. All three are catchable in code review, but none of them disappears just because the wrapper exists.

Sanity check across four call sites

The pattern carries a lot of weight, so tracing concrete inputs against the new signatures is worth the space. Traces 1 through 3 apply identically to both implementations. Trace 4 is where they part ways.

// 1. Operational fields only: compiles in both.
logger.info("onboarding.start", {
  customerId: customer.id,             // string
  marketingOptIn: customer.marketingOptIn, // boolean
});

// 2. A single PII field: error on `email` in both.
//    Brand: Type 'string & $brand<"PII">' is not assignable to type 'never'.
//    Class: Type 'PII'                    is not assignable to type 'never'.
logger.info("onboarding.start", { email: customer.email });

// 3. Mixed payload: error on `iban` only, in both.
metrics.increment("onboarding.requested", {
  region: "eu-central",                // string, OK
  iban: customer.iban,                 // PII, errors here
});

// 4. PII coerced through a template literal — behavior depends on implementation.
//    Brand version:  compiles, message contains the raw email. The brand is gone.
//    Class version:  compiles, message becomes "user [REDACTED] logged in" at
//                    runtime because the wrapper's toString returns "[REDACTED]".
info(`user ${customer.email} logged in`);

All four match the intuition: PII either fails to compile or, in the class version, fails to print. The compile-time gate catches structured-context cases by name in both implementations; the wrapper additionally catches the template-literal case that the type system cannot see across.

Brand vs class: which to pick

The two implementations sit at different points on the same trade-off curve. Neither is universally correct.

AspectBrand string & z.$brand<"PII">Wrapper class class PII { ... }
Compile-time reject at logger/metrics/spanYesYes
Runtime defense (template literal, JSON.stringify, console.log)NoYes
Inferred typestring (structurally compatible)PII instance
Migration costLow; DB writes, API calls unchangedHigh; .reveal() at every legitimate boundary
When to chooseNew codebase or low template-literal risk; minimal disruptionHighest-sensitivity fields; team accepts boundary refactor

The default recommendation is the brand. It is cheap to introduce, structurally compatible with existing string-handling code, and catches the vast majority of accidental leaks at the named-key boundaries where reviewers spend their time. The compile-time gate at the observability sink does the heavy lifting. The remaining runtime hatches (template literals, JSON.stringify, as any) are catchable with lint rules and PR review.

The class is the right answer for the small subset of fields where the blast radius justifies the refactor cost. In a regulated context, that usually means three to five fields: a national identifier, raw IBAN, date of birth combined with name, and any tokenised but reversible identifier. Wrap those in the class; brand the rest.

What to avoid is running both implementations side by side in the same contracts package. Two PII identities in one repo produces ambiguity at every import site and undermines the “one shared identity per service” property the whole pattern depends on. Pick one identity per service. If the highest-sensitivity fields warrant the class wrapper, use the class everywhere; otherwise, use the brand everywhere.

Where this sits in the ecosystem

The pattern is not appearing in a vacuum. Three published approaches solve adjacent versions of the same problem, and naming them clarifies what the brand and the wrapper class add on top.

Effect-TS Redacted<A> is the direct conceptual ancestor of the wrapper class. Effect’s Redacted is also a wrapper with redacting toString and inspect, wired into Effect’s Logger. The class wrapper above is the framework-agnostic version of this idea: same redacting methods, no Effect runtime to adopt, drops into any Pino, Winston, or console codebase. The compile-time guard via SafeCtx<T> adds something Effect does not: the call itself becomes a type error before any runtime redactor runs.

Pino LogFnFields, shipped in v9.14.0 (October 2025), uses the same never trick the brand pattern above relies on. The brand-based SafeCtx is the value-type cousin of this: Pino bans by field name, the brand bans by value type. Field-name banning is excellent when the offending keys are stable and enumerable. It falls short the moment a developer writes { userKey: piiValue } under any key the field-name list does not know about; span attribute maps, metric tag bags, and structured error context objects all fail this test. The two mechanisms compose cleanly: use LogFnFields for known sensitive key names, use the brand-based guard for everything else.

Rust’s secrecy, sec, and safelog crates achieve the same outcome through trait coherence. Secret<T> does not implement Display or Debug, so println!("{}", secret) does not compile. TypeScript cannot do this directly because structural typing means anyone can write a toString. The structural-TypeScript equivalents are two. One is the brand-based value-type guard at the API boundary. The other, where runtime coverage is needed, is the wrapper class with redacting methods.

Allan Reyes’s “Read-Once Objects” essay names the gap directly. He wanted compile-time enforcement for a similar PII-shaped pattern, found it out of reach in TypeScript, and fell back to a Semgrep rule. Both the brand and the wrapper class close that gap without leaving the type system.

Where this fits

The compile-time pattern handles the bulk of accidental leaks: the field passed by name to a log context, the PII attached to a span attribute, the email stuffed into a metric tag. For a regulated project handling national identifiers, IBANs, and addresses, that turns out to be most of what reviewers catch in PRs. The runtime redactor (Pino redact paths, the cloud-side scrubber) still runs underneath, but it has less to do. With the brand, it picks up template-literal and JSON.stringify leaks. With the class, it picks up .reveal() misuse and third-party libraries logging on their own loggers.

The compliance argument is straightforward. GDPR Article 5(1)(c) requires data minimisation. The ICO’s guidance reads as a directive to not collect or retain personal data beyond what is necessary. That extends to log retention. A compile-time gate is the cheapest defensible answer to an audit question about how the system prevents accidental PII retention in observability tooling. It is verifiable in CI and auditable in git history. It does not depend on a runtime configuration being correct in every region and every deployment.

Closing

For most services, the brand is the right default. Define PII, pii, and SafeCtx in a contracts package. Change the signatures of your logger, metrics, and span functions to take a generic T constrained through SafeCtx<T>. Let the compiler walk you through the existing leaks. Reach for the wrapper class for the three or four fields with the highest blast radius (date of birth combined with name, national identifier, raw IBAN). Avoid running both implementations in the same contracts package; pick one identity per service. Keep Pino redaction and the cloud-side scrubber configured as the backstop. The compile-time gate is the primary control.

References

Related posts

MCP Advanced Patterns: Skills, Workflows, Integration, and RBAC

Enterprise-grade patterns for Model Context Protocol implementations including tool composition, multi-agent orchestration, role-based access control, and production observability.

mcpai-integrationrbac+4
Building Custom MCP Servers: A Production-Ready Guide

Learn how to build, secure, and deploy custom Model Context Protocol servers for your organization's internal systems with TypeScript, including authentication, monitoring, and Kubernetes deployment.

typescriptmcpnodejs+5
Type-Safe Lambda Middleware: Building Enterprise Patterns with Middy, Zod, and Builder Pattern

Learn to build maintainable, type-safe Lambda middleware using Middy's builder pattern, Zod validation, feature flags, and secrets management for enterprise serverless applications.

aws-lambdamiddymiddleware+8
HMAC: What It Is, How It Works, and When It Is the Wrong Tool

A grounded explainer for backend engineers: HMAC-SHA-256 for webhooks, signed URLs, and internal auth, with three-language code and the boundary where digital signatures take over.

securitycryptographyhmac+5
Idempotency: A Beginner's Guide to Safe Retries in APIs

A practical introduction to idempotency for developers building APIs, payment flows, and message consumers. Covers HTTP method semantics, idempotency keys, database upserts, and common pitfalls with working Node.js examples.

idempotencyapi-designdistributed-systems+4