Skip to main content

Overview

Codecs provide bidirectional transformation between two representations of data. They combine input validation, output validation, and reversible transformations.

Basic Codec

Create a codec using z.codec() with two schemas and transformation functions:
import * as z from 'zod';

const isoDateCodec = z.codec(
  z.iso.datetime(),  // Input: ISO string
  z.date(),          // Output: Date object
  {
    decode: (isoString) => new Date(isoString),  // ISO string → Date
    encode: (date) => date.toISOString()         // Date → ISO string
  }
);

// Forward decoding: ISO string → Date
const date = z.decode(isoDateCodec, "2024-01-15T10:30:00.000Z");
console.log(date); // Date object

// Backward encoding: Date → ISO string
const isoString = z.encode(isoDateCodec, new Date("2024-01-15T10:30:00.000Z"));
console.log(isoString); // "2024-01-15T10:30:00.000Z"

Codec Structure

A codec is defined with:
  • Input Schema (A): Validates the encoded form
  • Output Schema (B): Validates the decoded form
  • decode: Transforms AB (forward direction)
  • encode: Transforms BA (backward direction)
z.codec(
  inputSchema,   // Schema A
  outputSchema,  // Schema B
  {
    decode: (a) => b,  // A → B
    encode: (b) => a   // B → A
  }
);

Codec Operations

Decode (Forward)

Transform from input to output representation:
const stringNumberCodec = z.codec(
  z.string(),
  z.number(),
  {
    decode: (str) => Number.parseFloat(str),
    encode: (num) => num.toString()
  }
);

// Synchronous decode
const num = z.decode(stringNumberCodec, "42.5");
console.log(num); // 42.5

// Safe decode (doesn't throw)
const result = z.safeDecode(stringNumberCodec, "invalid");
if (result.success) {
  console.log(result.data);
} else {
  console.log(result.error);
}

Encode (Backward)

Transform from output back to input representation:
// Synchronous encode
const str = z.encode(stringNumberCodec, 42.5);
console.log(str); // "42.5"

// Safe encode (doesn't throw)
const result = z.safeEncode(stringNumberCodec, 42.5);
if (result.success) {
  console.log(result.data); // "42.5"
}

Async Operations

All codec operations support async transformations:
const asyncCodec = z.codec(
  z.string(),
  z.number(),
  {
    decode: async (str) => {
      await new Promise(resolve => setTimeout(resolve, 1));
      return Number.parseFloat(str);
    },
    encode: async (num) => {
      await new Promise(resolve => setTimeout(resolve, 1));
      return num.toString();
    }
  }
);

// Async decode
const decoded = await z.decodeAsync(asyncCodec, "42.5");
console.log(decoded); // 42.5

// Async encode
const encoded = await z.encodeAsync(asyncCodec, 42.5);
console.log(encoded); // "42.5"

// Safe async operations
const safeResult = await z.safeDecodeAsync(asyncCodec, "123");

Round-Trip Conversion

Codecs guarantee bidirectional transformation:
const isoDateCodec = z.codec(
  z.iso.datetime(),
  z.date(),
  {
    decode: (isoString) => new Date(isoString),
    encode: (date) => date.toISOString()
  }
);

const original = "2024-12-25T15:45:30.123Z";
const toDate = z.decode(isoDateCodec, original);
const backToString = z.encode(isoDateCodec, toDate);

console.log(backToString); // "2024-12-25T15:45:30.123Z"
console.log(backToString === original); // true

Codec Type Signatures

The type system ensures correct transformations:
const codec = z.codec(
  z.string(),
  z.number(),
  {
    // decode parameter must be: core.output<A> (string)
    // decode return must be: core.input<B> (number)
    decode: (value: string) => Number(value),
    
    // encode parameter must be: core.input<B> (number)
    // encode return must be: core.output<A> (string)
    encode: (value: number) => String(value)
  }
);

// Type inference works automatically
const decoded: number = z.decode(codec, "123");
const encoded: string = z.encode(codec, 123);

Codecs with Refinements

Add refinements to codec schemas:
const isoDateCodec = z.codec(
  z.iso.datetime(),
  z.date(),
  {
    decode: (isoString) => new Date(isoString),
    encode: (date) => date.toISOString()
  }
).refine((val) => val.getFullYear() === 2024, {
  error: "Year must be 2024"
});

// Valid 2024 date
const validDate = z.decode(isoDateCodec, "2024-01-15T10:30:00.000Z");
console.log(validDate.getFullYear()); // 2024

// Invalid year
const invalidResult = z.safeDecode(isoDateCodec, "2023-01-15T10:30:00.000Z");
if (!invalidResult.success) {
  console.log(invalidResult.error.issues);
  // [{ code: "custom", message: "Year must be 2024", ... }]
}

Complex Codec Example

Nested object with codec property:
const waypointSchema = z.object({
  name: z.string().min(1, "Waypoint name required"),
  difficulty: z.enum(["easy", "medium", "hard"]),
  coordinate: z.codec(
    z.string().regex(/^-?\d+,-?\d+$/, "Must be 'x,y' format"),
    z.object({ x: z.number(), y: z.number() })
      .refine((coord) => coord.x >= 0 && coord.y >= 0, {
        error: "Coordinates must be non-negative"
      }),
    {
      decode: (coordString: string) => {
        const [x, y] = coordString.split(",").map(Number);
        return { x, y };
      },
      encode: (coord: { x: number; y: number }) => `${coord.x},${coord.y}`
    }
  ).refine((coord) => coord.x <= 1000 && coord.y <= 1000, {
    error: "Coordinates must be within bounds"
  })
}).refine((waypoint) => {
  return waypoint.difficulty !== "hard" || waypoint.coordinate.x >= 100;
}, {
  error: "Hard waypoints must be at least 100 units from origin"
});

const inputWaypoint = {
  name: "Summit Point",
  difficulty: "medium" as const,
  coordinate: "150,200"
};

// Decode: coordinate string → coordinate object
const decoded = z.decode(waypointSchema, inputWaypoint);
console.log(decoded);
// {
//   name: "Summit Point",
//   difficulty: "medium",
//   coordinate: { x: 150, y: 200 }
// }

// Encode: coordinate object → coordinate string
const encoded = z.encode(waypointSchema, decoded);
console.log(encoded);
// {
//   name: "Summit Point",
//   difficulty: "medium",
//   coordinate: "150,200"
// }

Validation at Multiple Levels

Codecs validate at each level:
// Input validation (string format)
const result1 = z.safeDecode(waypointSchema, {
  name: "Test",
  difficulty: "easy",
  coordinate: "invalid"  // Fails regex validation
});
// Error: "Must be 'x,y' format"

// Output validation (coordinate constraints)
const result2 = z.safeDecode(waypointSchema, {
  name: "Test",
  difficulty: "easy",
  coordinate: "-5,10"  // Fails non-negative check
});
// Error: "Coordinates must be non-negative"

// Codec refinement (bounds check)
const result3 = z.safeDecode(waypointSchema, {
  name: "Test",
  difficulty: "easy",
  coordinate: "1500,2000"  // Exceeds bounds
});
// Error: "Coordinates must be within bounds"

// Object refinement (hard waypoint constraint)
const result4 = z.safeDecode(waypointSchema, {
  name: "Expert Point",
  difficulty: "hard",
  coordinate: "50,60"  // x < 100 for hard difficulty
});
// Error: "Hard waypoints must be at least 100 units from origin"

Mutating Refinements

Codecs support refinements that mutate data:
const A = z.codec(
  z.string(),
  z.string().trim(),
  {
    decode: (val) => val,
    encode: (val) => val
  }
);

console.log(z.decode(A, " asdf ")); // "asdf" (trimmed)
console.log(z.encode(A, " asdf ")); // "asdf" (trimmed)

// With .check() for inline checks
const B = z.codec(
  z.string(),
  z.string(),
  {
    decode: (val) => val,
    encode: (val) => val
  }
).check(z.trim(), z.maxLength(4));

console.log(z.decode(B, " asdf ")); // "asdf"
console.log(z.encode(B, " asdf ")); // "asdf"

Codec with Overwrites

Apply transformations after codec operations:
const stringPlusA = z.string().overwrite((val) => val + "a");

const A = z.codec(
  stringPlusA,
  stringPlusA,
  {
    decode: (val) => val,
    encode: (val) => val
  }
).overwrite((val) => val + "a");

console.log(z.decode(A, "")); // "aaa"
console.log(z.encode(A, "")); // "aaa"

Instance Checks

const codec = z.codec(z.iso.datetime(), z.date(), {
  decode: (iso) => new Date(iso),
  encode: (date) => date.toISOString()
});

// Codec is also a Pipe and Type
console.log(codec instanceof z.ZodCodec);     // true
console.log(codec instanceof z.ZodPipe);      // true
console.log(codec instanceof z.ZodType);      // true
console.log(codec instanceof z.core.$ZodCodec); // true

Error Handling

const isoDateCodec = z.codec(
  z.iso.datetime(),
  z.date(),
  {
    decode: (isoString) => new Date(isoString),
    encode: (date) => date.toISOString()
  }
);

// Input validation error
const result = z.safeDecode(isoDateCodec, "invalid-date");
if (!result.success) {
  console.log(result.error.issues);
  // [{
  //   code: "invalid_format",
  //   format: "datetime",
  //   message: "Invalid ISO datetime",
  //   origin: "string",
  //   path: [],
  //   pattern: "/^(?:(?:\\d\\d..."
  // }]
}

Best Practices

  1. Ensure reversibility - Encoding after decoding should return the original value
  2. Validate both directions - Both input and output schemas should have proper validation
  3. Use refinements for constraints - Add refinements to enforce additional rules
  4. Handle edge cases - Consider how transformations handle null, undefined, edge values
  5. Type safety - Let TypeScript infer types from codec definitions
  6. Async when needed - Use async transforms for I/O operations
Codecs are perfect for API serialization, database value conversion, and any scenario requiring reversible transformations with validation.
Make sure encode and decode functions are true inverses. Non-reversible codecs can lead to data loss or validation errors.

Build docs developers (and LLMs) love