A comprehensive guide to converting io-ts codecs to Zod schemas. Learn how to replace
t.type, t.string, and t.TypeOf with their Zod equivalents,
handle fp-ts Either patterns, and remove functional programming boilerplate.
Migrate your entire project with a single command:
npx schemashift-cli migrate ./src --from io-ts --to zod
io-ts to Zod migration requires a Pro or Team license. View pricing.
// schemas/user.ts
import * as t from 'io-ts';
import { pipe } from 'fp-ts/function';
import { fold } from 'fp-ts/Either';
const UserCodec = t.type({
id: t.number,
name: t.string,
email: t.string,
role: t.union([
t.literal('admin'),
t.literal('user'),
t.literal('guest')
]),
tags: t.array(t.string),
address: t.partial({
street: t.string,
city: t.string,
zip: t.string,
}),
});
type User = t.TypeOf<typeof UserCodec>;
// Decoding with Either
const result = pipe(
UserCodec.decode(input),
fold(
(errors) => { throw new Error('Invalid'); },
(user) => user
)
);
// schemas/user.ts
import { z } from 'zod';
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string(),
role: z.union([
z.literal('admin'),
z.literal('user'),
z.literal('guest')
]),
tags: z.array(z.string()),
address: z.object({
street: z.string(),
city: z.string(),
zip: z.string(),
}).partial(),
});
type User = z.infer<typeof UserSchema>;
// Parsing with safeParse
const result = UserSchema.safeParse(input);
if (!result.success) {
throw new Error('Invalid');
}
const user = result.data;
| io-ts | Zod | Notes |
|---|---|---|
t.string |
z.string() |
Note: Zod uses function calls |
t.number |
z.number() |
|
t.boolean |
z.boolean() |
|
t.type({ ... }) |
z.object({ ... }) |
All properties required |
t.partial({ ... }) |
z.object({ ... }).partial() |
All properties optional |
t.array(X) |
z.array(X) |
|
t.union([A, B]) |
z.union([A, B]) |
|
t.intersection([A, B]) |
A.merge(B) |
For object types; use .and() for others |
t.literal('x') |
z.literal('x') |
|
t.null |
z.null() |
|
t.undefined |
z.undefined() |
|
t.void |
z.void() |
|
t.unknown |
z.unknown() |
|
t.record(t.string, X) |
z.record(z.string(), X) |
|
t.tuple([A, B]) |
z.tuple([A, B]) |
|
t.keyof({ a: null, b: null }) |
z.enum(['a', 'b']) |
|
t.TypeOf<typeof X> |
z.infer<typeof X> |
Type extraction |
t.OutputOf<typeof X> |
z.infer<typeof X> |
Zod has no separate output type for basic schemas |
t.Int |
z.number().int() |
Branded integer becomes a refinement |
decode() returns an fp-ts Either. Replace pipe(codec.decode(input), fold(...))
with Zod's schema.safeParse(input) which returns { success, data, error }.
You will need to refactor any code that pattern-matches on Left/Right.
pipe from fp-ts/function is only used for codec decoding, SchemaShift removes the
import entirely. If used elsewhere, it is preserved. Review remaining fp-ts usage manually.
t.brand() creates nominal types. Zod uses .brand('Name') for similar behavior.
Complex brand predicates may need manual review since Zod brands are purely type-level.
codec.encode(), you will need a separate serialization step. SchemaShift adds a TODO comment.
t.Int as a branded type for integers. Zod uses a refinement:
z.number().int(). The branded nominal type is lost; use .brand('Int') if needed.
t.recursion() maps to z.lazy(). You must provide an explicit type annotation
for the Zod schema since TypeScript cannot infer recursive types: const Tree: z.ZodType<TreeType> = z.lazy(() => ...).
# Dry run first to preview changes
npx schemashift-cli migrate ./src --from io-ts --to zod --dry-run
# Run the migration
npx schemashift-cli migrate ./src --from io-ts --to zod
# With verbose output and HTML report
npx schemashift-cli migrate ./src --from io-ts --to zod --verbose --report html
# Export a unified diff for review
npx schemashift-cli migrate ./src --from io-ts --to zod --dry-run --output-diff changes.patch
import * as t from 'io-ts' with import { z } from 'zod't.type({}) calls to z.object({})t.partial({}) calls to z.object({}).partial()t.TypeOf<typeof X> with z.infer<typeof X>t.Int with z.number().int()t.union([]) to z.union([])safeParse()fp-ts importst.brand() to .brand() or .refine()codec.encode() usagepackage.json: add zod, remove io-ts and fp-ts (if unused)npx tsc --noEmit to verify type safety
io-ts decode() returns an fp-ts Either (Left for errors, Right for success).
Zod uses safeParse() which returns { success: boolean, data?, error? }.
Replace pipe(codec.decode(input), fold(...)) with
const result = schema.safeParse(input) and check result.success.
Yes. io-ts branded types like t.brand(t.number, (n): n is Positive => n > 0, 'Positive')
map to Zod's z.number().refine(n => n > 0).brand('Positive'). SchemaShift
auto-converts simple branded types and adds TODO comments for complex custom brands that need manual review.
SchemaShift removes fp-ts pipe imports that are only used for io-ts codec decoding.
If pipe is used elsewhere in your code for other fp-ts operations, it is preserved.
The tool analyzes usage context to determine which imports can be safely removed.
SchemaShift automates io-ts to Zod migration with AST-based transforms, fp-ts cleanup, and inline TODO guidance.
Get SchemaShift