Zod Codecs Guide: Encode & Decode in Zod v4
Core Concepts Refresh
Input vs Output Divergence
In Zod, every schema has two types: input and output.
input→ the raw data coming into your system (API responses, form data, DB values)output→ the validated or transformed data your application actually uses
In basic schemas, both are the same. But when you use transformations, they diverge.
Example:
const schema = z.string().transform((val) => Number(val));
Here, the input is string, but the output becomes number.
This creates a limitation: once transformed, Zod does not know how to reverse the operation. You lose the ability to safely convert the data back to its original representation.
Codecs solve this by explicitly defining both directions.
.parse() vs .decode() vs .encode()
Zod Codecs introduce clear methods for handling bidirectional data flow.
-
.parse()
The default Zod method. It validates input and applies forward transformations. For codecs, it behaves similarly to.decode(). -
.decode()
Convertsinput → output.
This is used when data enters your system and needs to be validated and transformed into a usable format.Example:
const numberCodec = z.codec({ decode: (val: string) => Number(val), encode: (val: number) => val.toString() });
numberCodec.decode("42"); // 42 -
.encode()
Convertsoutput → input.
This is used when data leaves your system (e.g., API calls, storage, URLs), ensuring it is converted back into a valid external format.Example:
numberCodec.encode(42); // "42"
Together, .decode() and .encode() define a two-way contract, allowing the same schema to safely handle both incoming and outgoing data.
The "Why" (Strategic Advantage)
Traditional .transform() is one-directional. It lets you convert input → output, but provides no reliable way to reverse that transformation. This becomes a serious limitation in full-stack TypeScript applications where the same schema must work across multiple layers.
In real-world systems, data constantly moves in both directions:
- API responses → application state
- application state → API requests
- database storage → domain objects
- domain objects → serialized formats
With .transform(), you end up duplicating logic for encoding and decoding, which introduces inconsistency and bugs.
Codecs solve this by defining both directions explicitly using .decode() and .encode(). This creates a single, shared contract that works across frontend and backend.
The strategic advantage is clear:
- One schema for both read and write operations
- No duplication of serialization logic
- Strong type safety in both directions
- Better alignment with real data flow in production systems
This makes codecs especially valuable in monorepos, API-driven architectures, and frameworks where schemas are shared across boundaries.
Deep Dive: Composability & Nesting
Codecs are fully composable and can be used anywhere a Zod schema is used. This means they work seamlessly inside z.object, z.array, and other nested structures.
Example:
const dateCodec = z.codec({ decode: (val: string) => new Date(val), encode: (date: Date) => date.toISOString() });
const userSchema = z.object({
id: z.string(),
profile: z.object({
name: z.string(),
createdAt: dateCodec,
}),
posts: z.array(
z.object({
title: z.string(),
publishedAt: dateCodec,
})
),
});
When you call .decode(), all nested codecs are applied automatically:
const decoded = userSchema.decode({
id: "1",
profile: { name: "Sharukhan", createdAt: "2026-01-01T00:00:00.000Z" },
posts: [{ title: "Hello", publishedAt: "2026-01-02T00:00:00.000Z" }],
});
All date fields are converted into Date objects.
Similarly, .encode() walks the same structure in reverse, converting everything back into serializable formats.
This composability allows you to define transformation logic once and have it consistently applied across deeply nested data structures.
Error Handling & Validation Flow
The "Two-Pass" Validation Model
Encoding in codecs is not just a simple reverse function. Zod performs validation in two stages to ensure correctness.
- Validate the
outputbefore encoding - Run the
.encode()transformation - Validate the resulting
inputagain
This ensures:
- Only valid internal data is encoded
- The encoded result still conforms to the expected input schema
This two-pass approach prevents subtle bugs where encoded data becomes invalid or inconsistent.
Handling Errors During .encode()
Errors during .encode() can occur in two places:
- The
outputdoes not match the expected schema - The encoded result is invalid as an
input
To safely handle this in production, use .safeEncode():
const result = userSchema.safeEncode(data);
if (!result.success) {
console.log(result.error.format());
}
This avoids throwing exceptions and gives structured error details.
If you need exception-based handling:
try {
userSchema.encode(data);
} catch (err) {
if (err instanceof z.ZodError) {
console.log(err.errors);
}
}
Using .safeEncode() is generally preferred for robust pipelines, especially in APIs and background processing systems.
Advanced Ecosystem Patterns
Database Interop (JSON Columns)
A common production scenario is working with databases that store structured data as JSON strings. Without codecs, you typically end up scattering JSON.parse and JSON.stringify across your codebase, which leads to duplication and inconsistent handling.
Codecs centralize this logic.
Example:
const jsonCodec = z.codec({ decode: (val: string) => JSON.parse(val), encode: (val: unknown) => JSON.stringify(val) });
const dbSchema = z.object({
id: z.string(),
metadata: jsonCodec,
});
When reading from the database:
const row = { id: "1", metadata: '{"role":"admin"}' };
const parsed = dbSchema.decode(row);
Now metadata is a properly typed object instead of a raw string.
When writing back:
const encoded = dbSchema.encode(parsed);
The object is automatically converted back into a JSON string.
This pattern ensures:
- A single source of truth for serialization logic
- Cleaner service and repository layers
- Strong type safety across database boundaries
URL Search Params State
Managing application state in URL query parameters is often messy, especially when dealing with numbers, defaults, or multiple fields. Codecs provide a clean abstraction for this.
Example:
const searchParamsCodec = z.codec({
decode: (params: URLSearchParams) => ({
page: Number(params.get("page") ?? 1),
filter: params.get("filter") ?? "all",
}),
encode: (state: { page: number; filter: string }) => {
const params = new URLSearchParams();
params.set("page", state.page.toString());
params.set("filter", state.filter);
return params;
},
});
Decoding from the URL:
const state = searchParamsCodec.decode(new URLSearchParams("?page=2"));
Encoding back to the URL:
const params = searchParamsCodec.encode(state);
This pattern is especially useful in frontend applications where URL state needs to stay in sync with UI state.
Benefits include:
- Strong typing for query parameters
- Default value handling in one place
- Easy round-trip conversion between URL and application state
- Cleaner integration with routing libraries and frameworks
Comparison Table
-
Codec
- Direction: bi-directional
- Supports
input → output: yes - Supports
output → input: yes - Type symmetry: maintained
- Async support: yes
- Best use case: full-stack schemas, shared contracts, serialization + deserialization
-
Pipe
- Direction: one-way
- Supports
input → output: yes - Supports
output → input: no - Type symmetry: not maintained
- Async support: yes
- Best use case: validation pipelines and chaining transformations
-
Transform
- Direction: one-way
- Supports
input → output: yes - Supports
output → input: no - Type symmetry: not maintained
- Async support: yes
- Best use case: simple transformations where reverse mapping is not needed
Best Practices & Gotchas
Why .transform() Breaks .encode()
.transform() only defines a forward transformation. It converts input → output, but does not provide any information about how to reverse that process.
Example:
const schema = z.string().transform((val) => val.length);
Here, a string becomes a number, but there is no reliable way to reconstruct the original string from the number.
Because of this, schemas using .transform() cannot support .encode(). Codecs enforce explicit reverse logic, which is why they are required for bidirectional use cases.
When to Use z.invertCodec()
z.invertCodec() is useful when you want to reverse the direction of an existing codec without rewriting its logic.
Example:
const forward = z.codec({
decode: (val: string) => Number(val),
encode: (val: number) => val.toString(),
});
const inverted = z.invertCodec(forward);
Now:
inverted.decode(42)returns"42"inverted.encode("42")returns42
This is helpful when:
- You need the same transformation in reverse contexts
- You want to reuse codecs across different layers
- You are adapting existing logic without duplication
Performance Considerations
Codecs introduce additional processing because they handle both encoding and decoding, often with validation in both directions.
For high-frequency or performance-critical paths:
- Keep
encodeanddecodefunctions lightweight - Avoid expensive computations inside codecs
- Prefer pure, deterministic transformations
- Be cautious with deeply nested codecs in large datasets
Example concern:
decode: (val) => heavyComputation(val)
In such cases, consider pre-processing or caching strategies instead of embedding heavy logic directly inside the codec.
Keep Codecs Focused
A codec should only handle transformation between two representations of the same data. It should not include unrelated business logic.
Avoid:
decode: (val) => {
// validation + transformation + side effects
}
Prefer:
decode: parseJson
Keeping codecs focused ensures:
- Better reusability
- Easier testing
- Clear separation of concerns
Closing Perspective
Zod Codecs elevate schemas from simple validation tools to full data contracts that operate across system boundaries.
They provide a structured way to handle both incoming and outgoing data using a single definition, eliminating duplication and reducing inconsistencies.
In modern TypeScript applications—especially those spanning frontend, backend, and persistence layers—codecs offer a reliable and scalable approach to managing data transformations.
Adopting codecs is not just about cleaner code; it’s about aligning your data layer with how real-world systems actually move and transform data.
Note: This article is AI-assisted and may contain minor inaccuracies.