zod icon indicating copy to clipboard operation
zod copied to clipboard

Branded schemas do not work as expected in Codecs

Open KilgoreTroute07 opened this issue 1 month ago • 1 comments

I've noticed there are issues when using Branded schemas in codecs. The issue is different depending on if the branded type is the "input" schema or the "output" schema, but both present issues.

When using Branded "output"

When using a Branded schemas as the output schema, the type of the argument for the "encode" function is not properly constrained to the Branded type.

export const SsnSchema = z
  .string()
  .regex(/^\d{3}-\d{2}-\d{4}$/, "Must be in format xxx-xx-xxxx")
  .regex(
    /^(?!666|000|9\d{2})\d{3}-?(?!00)\d{2}-?(?!0000)\d{4}$/,
    "Must be a valid SSN"
  )
  .brand<"Ssn">();
export type Ssn = z.infer<typeof SsnSchema>;
export const SsnNumberSchema = z.number().min(100000000).max(999999999);

export const SsnNumberToStringCodec = z.codec(SsnNumberSchema, SsnSchema, {
  decode: (num) =>
    `${num.toString().slice(0, 3)}-${num.toString().slice(3, 5)}-${num.toString().slice(5, 9)}`,
  encode: (ssnString) => Number(ssnString.replace(/-/g, "")), // ssnString is just "string" not "Ssn"
});

export const ssnStringParsed = SsnNumberToStringCodec.parse(123456789);
export const ssnStringDecoded = SsnNumberToStringCodec.decode(123456789);
export const ssnNumberEncoded = SsnNumberToStringCodec.encode(
  SsnSchema.parse("123-45-6789")
);

In this example the argument ssnString of the encode function is simply typed as string as opposed to the branded type.

When using Branded "input"

When using a branded schema as the input type, full typesafety is no longer enforced when calling decode, and type produced by encode is loose.

export const SsnSchema = z
  .string()
  .regex(/^\d{3}-\d{2}-\d{4}$/, "Must be in format xxx-xx-xxxx")
  .regex(
    /^(?!666|000|9\d{2})\d{3}-?(?!00)\d{2}-?(?!0000)\d{4}$/,
    "Must be a valid SSN"
  )
  .brand<"Ssn">();
export type Ssn = z.infer<typeof SsnSchema>;
export const SsnNumberSchema = z.number().min(100000000).max(999999999);

export const SsnStringToNumberCodec = z.codec(SsnSchema, SsnNumberSchema, {
  decode: (ssnString) => Number(ssnString.replace(/-/g, "")),
  encode: (num) =>
    SsnSchema.parse(
      `${num.toString().slice(0, 3)}-${num.toString().slice(3, 5)}-${num.toString().slice(5, 9)}`
    ),
});

export const ssnNumberParsed = SsnStringToNumberCodec.parse("123-45-6789");
export const ssnNumberDecoded = SsnStringToNumberCodec.decode("123-45-6789"); // Decode doesn't Require Brand
export const ssnStringEncoded = SsnStringToNumberCodec.encode(123456789); // Encode Doesn't Produce Branded Output

In this example you can see that when calling decode it will just accept the loose type string and not the branded type. Also when calling encode with a number, the produced type is just string and not the branded type. Meaning the value must be re-parsed before it can be passed to a function expecting a Ssn;

KilgoreTroute07 avatar Nov 06 '25 16:11 KilgoreTroute07

Hi @KilgoreTroute07! I'm Dosu and I’m helping the zod team.

You're correct—this is intentional and by design in Zod 4. Branded types in Zod are a TypeScript-only construct for nominal typing, and only affect the output type of a schema, not the input type. That means codec encode and decode functions operate on the underlying primitive types (like string or number), not the branded types, and the brand is only enforced after parsing or decoding with the branded schema. This is to avoid requiring type casts for regular data structures and to keep input types practical for most users [source].

If you need a branded value after encoding, you'll need to explicitly re-parse it with the branded schema (e.g., SsnSchema.parse(...)). There's no built-in way in Zod to enforce branded types in codec signatures or to have encode/decode functions operate directly on branded types [source]. This asymmetry is intentional to ensure round-trip type safety and predictable validation [source].

For best type inference, use z.output<typeof Schema> instead of deprecated _output properties [source]. If you need stricter constraints, you can use type assertions or wrap your encode/decode functions, but these are manual workarounds.

If you have further requirements or edge cases, let me know! Otherwise, feel free to close the issue if this answers your question.

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other  Chat with Dosu Join Discord Share on X

dosubot[bot] avatar Nov 06 '25 17:11 dosubot[bot]