3.1 KiB
Schema migration
Practical reference for migrating data types in packages/opencode from Zod-first definitions to Effect Schema with Zod compatibility shims.
Goal
Use Effect Schema as the source of truth for domain models, IDs, inputs, outputs, and typed errors.
Keep Zod available at existing HTTP, tool, and compatibility boundaries by exposing a .zod field derived from the Effect schema.
Preferred shapes
Data objects
Use Schema.Class for structured data.
export class Info extends Schema.Class<Info>("Foo.Info")({
id: FooID,
name: Schema.String,
enabled: Schema.Boolean,
}) {
static readonly zod = zod(Info)
}
If the class cannot reference itself cleanly during initialization, use the existing two-step pattern:
const _Info = Schema.Struct({
id: FooID,
name: Schema.String,
})
export const Info = Object.assign(_Info, {
zod: zod(_Info),
})
Errors
Use Schema.TaggedErrorClass for domain errors.
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("FooNotFoundError", {
id: FooID,
}) {}
IDs and branded leaf types
Keep branded/schema-backed IDs as Effect schemas and expose static readonly zod for compatibility when callers still expect Zod.
Compatibility rule
During migration, route validators, tool parameters, and any existing Zod-based boundary should consume the derived .zod schema instead of maintaining a second hand-written Zod schema.
The default should be:
- Effect Schema owns the type
.zodexists only as a compatibility surface- new domain models should not start Zod-first unless there is a concrete boundary-specific need
When Zod can stay
It is fine to keep a Zod-native schema temporarily when:
- the type is only used at an HTTP or tool boundary
- the validator depends on Zod-only transforms or behavior not yet covered by
zod() - the migration would force unrelated churn across a large call graph
When this happens, prefer leaving a short note or TODO rather than silently creating a parallel schema source of truth.
Ordering
Migrate in this order:
- Shared leaf models and
schema.tsfiles - Exported
Info,Input,Output, and DTO types - Tagged domain errors
- Service-local internal models
- Route and tool boundary validators that can switch to
.zod
This keeps shared types canonical first and makes boundary updates mostly mechanical.
Checklist
- Shared
schema.tsleaf models are Effect Schema-first - Exported
Info/Input/Outputtypes useSchema.Classwhere appropriate - Domain errors use
Schema.TaggedErrorClass - Migrated types expose
.zodfor back compatibility - Route and tool validators consume derived
.zodinstead of duplicate Zod definitions - New domain models default to Effect Schema first
Notes
- Use
@/util/effect-zodfor all Schema -> Zod conversion. - Prefer one canonical schema definition. Avoid maintaining parallel Zod and Effect definitions for the same domain type.
- Keep the migration incremental. Converting the domain model first is more valuable than converting every boundary in the same change.