Migration Guide: Zod to Effect Schema
This guide helps you migrate from Zod to Effect Schema. We'll cover common patterns and their Effect Schema equivalents.
Installation
First, install Effect:
bash
npm install effectUpdate your imports:
typescript
// Before (Zod)
import { z } from "zod"
// After (Effect Schema)
import { Schema } from "effect"Primitives
Strings
typescript
z.string()
z.string().min(1)
z.string().max(100)
z.string().length(5)
z.string().email()
z.string().url()
z.string().uuid()
z.string().regex(/pattern/)
z.string().startsWith("prefix")
z.string().endsWith("suffix")
z.string().trim()
z.string().toLowerCase()
z.string().toUpperCase()typescript
Schema.String
Schema.String.pipe(Schema.minLength(1)) // or Schema.NonEmptyString
Schema.String.pipe(Schema.maxLength(100))
Schema.String.pipe(Schema.length(5))
Schema.String.pipe(Schema.pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/))
Schema.String.pipe(Schema.pattern(/^https?:\/\/.+/))
Schema.UUID
Schema.String.pipe(Schema.pattern(/pattern/))
Schema.String.pipe(Schema.startsWith("prefix"))
Schema.String.pipe(Schema.endsWith("suffix"))
Schema.Trim // transformation
Schema.Lowercase // transformation
Schema.Uppercase // transformationNumbers
typescript
z.number()
z.number().int()
z.number().positive()
z.number().negative()
z.number().nonnegative()
z.number().nonpositive()
z.number().min(5)
z.number().max(10)
z.number().finite()
z.number().safe()typescript
Schema.Number
Schema.Number.pipe(Schema.int()) // or Schema.Int
Schema.Number.pipe(Schema.positive()) // or Schema.Positive
Schema.Number.pipe(Schema.negative()) // or Schema.Negative
Schema.Number.pipe(Schema.nonNegative()) // or Schema.NonNegative
Schema.Number.pipe(Schema.nonPositive()) // or Schema.NonPositive
Schema.Number.pipe(Schema.greaterThanOrEqualTo(5))
Schema.Number.pipe(Schema.lessThanOrEqualTo(10))
Schema.Number.pipe(Schema.finite()) // or Schema.Finite
Schema.Number.pipe(Schema.int()) // Safe integers via int()Booleans and Others
typescript
z.boolean()
z.bigint()
z.date()
z.undefined()
z.null()
z.void()
z.any()
z.unknown()
z.never()typescript
Schema.Boolean
Schema.BigIntFromSelf // or Schema.BigInt for string→bigint
Schema.DateFromSelf // or Schema.DateFromString
Schema.Undefined
Schema.Null
Schema.Void
Schema.Any // avoid if possible
Schema.Unknown
Schema.NeverObjects
typescript
const User = z.object({
name: z.string(),
age: z.number()
})
// Partial
User.partial()
// Required
User.required()
// Pick
User.pick({ name: true })
// Omit
User.omit({ age: true })
// Extend
User.extend({ email: z.string() })
// Merge
User.merge(OtherSchema)
// Strict (no extra keys)
z.object({}).strict()typescript
const User = Schema.Struct({
name: Schema.String,
age: Schema.Number
})
// Partial
Schema.partial(User)
// Required
Schema.required(User)
// Pick
User.pipe(Schema.pick("name"))
// Omit
User.pipe(Schema.omit("age"))
// Extend
User.pipe(Schema.extend(Schema.Struct({ email: Schema.String })))
// Merge (use extend)
User.pipe(Schema.extend(OtherSchema))
// Strict (use onExcessProperty option)
Schema.decodeUnknownSync(User)(data, { onExcessProperty: "error" })Arrays
typescript
z.array(z.string())
z.array(z.string()).nonempty()
z.array(z.string()).min(1)
z.array(z.string()).max(10)
z.array(z.string()).length(5)
z.tuple([z.string(), z.number()])typescript
Schema.Array(Schema.String)
Schema.NonEmptyArray(Schema.String)
Schema.Array(Schema.String).pipe(Schema.minItems(1))
Schema.Array(Schema.String).pipe(Schema.maxItems(10))
Schema.Array(Schema.String).pipe(Schema.itemsCount(5))
Schema.Tuple(Schema.String, Schema.Number)Unions
typescript
z.union([z.string(), z.number()])
z.string().or(z.number())
z.discriminatedUnion("type", [
z.object({ type: z.literal("a"), value: z.string() }),
z.object({ type: z.literal("b"), count: z.number() })
])typescript
Schema.Union(Schema.String, Schema.Number)
// Same as above
Schema.Union(
Schema.Struct({ type: Schema.Literal("a"), value: Schema.String }),
Schema.Struct({ type: Schema.Literal("b"), count: Schema.Number })
)Literals and Enums
typescript
z.literal("active")
z.literal(42)
z.enum(["pending", "active", "done"])
enum Status { Pending, Active, Done }
z.nativeEnum(Status)typescript
Schema.Literal("active")
Schema.Literal(42)
Schema.Literal("pending", "active", "done")
enum Status { Pending, Active, Done }
Schema.Enums(Status)Optional and Nullable
typescript
z.string().optional() // string | undefined
z.string().nullable() // string | null
z.string().nullish() // string | null | undefined
z.string().default("hi") // defaults to "hi"typescript
Schema.optional(Schema.String) // string | undefined
Schema.NullOr(Schema.String) // string | null
Schema.NullishOr(Schema.String) // string | null | undefined
Schema.optional(Schema.String, { default: () => "hi" })Records
typescript
z.record(z.string()) // Record<string, string>
z.record(z.string(), z.number()) // Record<string, number>typescript
Schema.Record({ key: Schema.String, value: Schema.String })
Schema.Record({ key: Schema.String, value: Schema.Number })Refinements
typescript
z.string().refine(
(s) => s.length > 0,
"Must not be empty"
)
z.string().superRefine((s, ctx) => {
if (s.length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Must not be empty"
})
}
})typescript
Schema.String.pipe(
Schema.filter((s) => s.length > 0, {
message: () => "Must not be empty"
})
)
// For multiple issues, use filterEffect
Schema.String.pipe(
Schema.filter((s) => {
if (s.length === 0) return "Must not be empty"
if (!/[A-Z]/.test(s)) return "Must contain uppercase"
return true
})
)Transformations
typescript
z.string().transform((s) => s.length)
z.coerce.number() // coerce to number
z.coerce.date() // coerce to datetypescript
// Effect Schema transformations are bidirectional
Schema.transform(
Schema.String,
Schema.Number,
{
decode: (s) => s.length,
encode: (n) => "x".repeat(n) // Must provide reverse
}
)
Schema.NumberFromString // string ↔ number
Schema.DateFromString // string ↔ DateParsing Methods
typescript
schema.parse(data) // throws on error
schema.safeParse(data) // returns { success, data/error }
schema.parseAsync(data) // async version
schema.safeParseAsync(data) // async safe versiontypescript
Schema.decodeUnknownSync(schema)(data) // throws on error
Schema.decodeUnknownEither(schema)(data) // returns Either
Schema.decodeUnknownOption(schema)(data) // returns Option
Schema.decodeUnknownPromise(schema)(data) // returns Promise
Schema.decodeUnknown(schema)(data) // returns EffectType Inference
typescript
type User = z.infer<typeof UserSchema>
type Input = z.input<typeof UserSchema>
type Output = z.output<typeof UserSchema>typescript
type User = typeof UserSchema.Type // Output/decoded type
type Input = typeof UserSchema.Encoded // Input/encoded type
// Type and Encoded replace input/outputBrands
typescript
const UserId = z.string().brand<"UserId">()
type UserId = z.infer<typeof UserId>typescript
const UserId = Schema.String.pipe(Schema.brand("UserId"))
type UserId = typeof UserId.TypeCommon Patterns
API Response Handling
typescript
const ApiResponse = z.object({
data: UserSchema,
timestamp: z.string().datetime()
})
async function fetchUser(id: string) {
const res = await fetch(`/api/users/${id}`)
const json = await res.json()
return ApiResponse.parse(json)
}typescript
const ApiResponse = Schema.Struct({
data: UserSchema,
timestamp: Schema.DateFromString
})
async function fetchUser(id: string) {
const res = await fetch(`/api/users/${id}`)
const json = await res.json()
return Schema.decodeUnknownSync(ApiResponse)(json)
}
// Encoding for requests
async function updateUser(user: User) {
const body = Schema.encodeSync(UserSchema)(user)
await fetch(`/api/users/${user.id}`, {
method: "PUT",
body: JSON.stringify(body)
})
}Form Validation
typescript
const FormSchema = z.object({
email: z.string().email(),
password: z.string().min(8)
})
function validateForm(data: unknown) {
const result = FormSchema.safeParse(data)
if (!result.success) {
return result.error.flatten()
}
return result.data
}typescript
const FormSchema = Schema.Struct({
email: Schema.String.pipe(Schema.pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)),
password: Schema.String.pipe(Schema.minLength(8))
})
function validateForm(data: unknown) {
const result = Schema.decodeUnknownEither(FormSchema)(data)
if (Either.isLeft(result)) {
return ParseResult.ArrayFormatter.formatIssueSync(result.left.issue)
}
return result.right
}Migration Tips
- Start with leaf schemas: Migrate the simplest schemas first
- Test encoding: Effect Schema encodes too—verify your serialization works
- Use strict mode: Enable
onExcessProperty: "error"to match Zod's strict behavior - Check transforms: Zod transforms are one-way; Effect Schema needs both directions
- Update error handling: Error formats differ; update error display code
- Consider Effect: If migrating a large codebase, consider adopting Effect for other benefits
Need Help?
- Effect Discord - Active community
- GitHub Issues - Bug reports and questions
- Effect Documentation - Full Effect ecosystem docs