Skip to content

[Feature Request] Permissive Codecs (Asymmetric Inputs with Canonical Outputs) #6219

Description

@KingSora

When working with legacy data, flexible APIs, or lenient input formats (e.g., accepting both "123" and 123, or loose string dates alongside ISO strings), we often need schemas that accept multiple "permissive" types on input, but strictly normalize and output a single "canonical" type.

Currently, standard Codec require strict bi-directional mapping between an "in" schema and an "out" schema. Handling permissive variants currently requires manual handling of the codec, which breaks encapsulation and loses the benefits of the Codec abstraction when passing it across boundaries.

I would like to propose a concept for "Permissive Codecs".

A Permissive Codec allows defining additional "permissive" input and output schemas alongside the primary "canonical" "in" and "out" schemas.
Currently a codec produces the following types:

const dateCodec = z.codec(z.union([z.date(), z.instanceof(Temporal.Instant)]), z.string(), {
  // dateOrInstant: `Date | Temporal.Instant`, return `string`
  decode: (dateOrInstant) => dateOrInstant instanceof Date ? dateOrInstant.toISOString() : dateOrInstant.toString(),
  // string: `string`, return `Date | Temporal.Instant`
  encode: (string) => Temporal.Instant.from(string),
});

// decoded: `string`, decodeInput: `Date | Temporal.Instant`
const decoded = dateCodec.decode(decodeInput);
// encoded: `Date | Temporal.Instant`, encodeInput: `string`
const encoded = dateCodec.encode(encodeInput);

Notice how the encoded type is Date | Temporal.Instant even though it logically can only be Temporal.Instant, because the encode function only produces that output. So in this example I want to be able to put Date | Temporal.Instant into and codec to produce a string, but when putting in a string I want to get just the Temporal.Instant.

In other words the "canonical" "in" schema is z.instanceof(Temporal.Instant) and the "permissive" "in" schema is z.date(). In this example the "out"schema z.string() is "canonical" while there is no "permissive" "out" schema. Which means the current standard codec is a permissive codec with just "canonical" schemas and without any permissive schemas.

Conceptual API Example:

const dateCodec = z.permissive({
  in: z.instanceof(Temporal.Instant),
  inPermissive: z.date(),
  out: z.string(),
}, {
  // dateOrInstant: `Date | Temporal.Instant`, return `string`
  decode: (dateOrInstant) => dateOrInstant instanceof Date ? dateOrInstant.toISOString() : dateOrInstant.toString(),
  // string: `string`, return `Temporal.Instant`
  encode: (string) => Temporal.Instant.from(string),
});

// decoded: `string`, decodeInput: `Date | Temporal.Instant`
const decoded = dateCodec.decode(decodeInput);
// encoded: `Temporal.Instant`, encodeInput: `string`
const encoded = dateCodec.encode(encodeInput);

Notice how the decode function in the z.permissive function takes a union of the in and inPermissive type and returns the out type, while the encode function takes the out and the here absent outPermissive type and returns the in type.

One type which would immediately benefit from this is the z.stringbool() codec, because the behavior could be described without ambiguity. The docs give the example:

const stringbool = z.stringbool({ truthy: ["yes", "y"], falsy: ["no", "n"] });
 
stringbool.encode(true);    // => "yes"
stringbool.encode(false);   // => "no"

In this case a permissive codec could define z.literal("yes").or(z.literal("no") as the "canonical" type and all other values as the "permissive" type for a less ambiguous type experience with codecs.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions