A complete guide to converting io-ts codecs to Effect Schema. Stay in the functional programming
ecosystem while modernizing your validation layer. Convert t.type to
S.Struct, replace fp-ts pipe with Effect pipe, and gain access to the full
Effect ecosystem.
Convert your io-ts codecs to Effect Schema:
npx schemashift-cli migrate ./src --from io-ts --to effect
io-ts to Effect Schema migration requires a Pro or Team license. View pricing.
// schemas/config.ts
import * as t from 'io-ts';
import { pipe } from 'fp-ts/function';
import { fold } from 'fp-ts/Either';
const DatabaseConfig = t.type({
host: t.string,
port: t.number,
database: t.string,
ssl: t.boolean,
});
const AppConfig = t.type({
db: DatabaseConfig,
env: t.union([
t.literal('development'),
t.literal('staging'),
t.literal('production'),
]),
features: t.array(t.string),
limits: t.partial({
maxConnections: t.number,
timeout: t.number,
}),
});
type AppConfig = t.TypeOf<typeof AppConfig>;
const ServerConfig = t.intersection([
AppConfig,
t.type({ port: t.number }),
]);
// Decoding
const parseConfig = (raw: unknown): AppConfig =>
pipe(
AppConfig.decode(raw),
fold(
(errors) => { throw new Error(String(errors)); },
(config) => config
)
);
// schemas/config.ts
import * as S from '@effect/schema/Schema';
import { pipe } from 'effect/Function';
const DatabaseConfig = S.Struct({
host: S.String,
port: S.Number,
database: S.String,
ssl: S.Boolean,
});
const AppConfig = S.Struct({
db: DatabaseConfig,
env: S.Union(
S.Literal('development'),
S.Literal('staging'),
S.Literal('production'),
),
features: S.Array(S.String),
limits: S.partial(S.Struct({
maxConnections: S.Number,
timeout: S.Number,
})),
});
type AppConfig = S.Schema.Type<typeof AppConfig>;
const ServerConfig = pipe(
AppConfig,
S.extend(S.Struct({ port: S.Number })),
);
// Decoding
const parseConfig = (raw: unknown): AppConfig =>
S.decodeUnknownSync(AppConfig)(raw);
| io-ts | Effect Schema | Notes |
|---|---|---|
t.string |
S.String |
Capitalized, no parentheses |
t.number |
S.Number |
|
t.boolean |
S.Boolean |
|
t.type({ ... }) |
S.Struct({ ... }) |
|
t.partial({ ... }) |
S.partial(S.Struct({ ... })) |
Wrapping function |
t.array(X) |
S.Array(X) |
|
t.union([A, B]) |
S.Union(A, B) |
Spread args, not array |
t.intersection([A, B]) |
pipe(A, S.extend(B)) |
Uses Effect pipe |
t.literal('x') |
S.Literal('x') |
|
t.null |
S.Null |
|
t.undefined |
S.Undefined |
|
t.void |
S.Void |
|
t.unknown |
S.Unknown |
|
t.record(t.string, X) |
S.Record({ key: S.String, value: X }) |
|
t.tuple([A, B]) |
S.Tuple(A, B) |
Spread args, not array |
t.keyof({ a: null }) |
S.Literal('a') |
Or S.Union(S.Literal('a'), ...) |
t.TypeOf<typeof X> |
S.Schema.Type<typeof X> |
|
t.OutputOf<typeof X> |
S.Schema.Type<typeof X> |
|
pipe (fp-ts) |
pipe (effect/Function) |
Same concept, different import |
codec.decode(input) |
S.decodeUnknownSync(schema)(input) |
Throws on failure |
codec.encode(value) |
S.encodeSync(schema)(value) |
Encode is preserved |
codec.decode(input) returning Either. Effect Schema uses
S.decodeUnknownSync(schema)(input) which throws on failure, or
S.decodeUnknownEither(schema)(input) for Either-style. Unlike Zod, Effect Schema
preserves bidirectional encoding via S.encodeSync().
t.brand() maps to Effect Schema's S.brand(). The API is similar:
pipe(S.Number, S.brand('Positive'), S.filter(n => n > 0)). Effect Schema
brands are type-level and compose well with the pipe pattern.
t.recursion('name', () => codec) becomes
S.Struct({ children: S.Array(S.suspend(() => TreeSchema)) }).
You must provide explicit type annotations for recursive schemas in both libraries.
t.union([A, B, C]) passes an array. Effect Schema uses spread arguments:
S.Union(A, B, C). Forgetting to remove the array brackets is a common migration error.
Effect.runPromise, Layer, and other Effect primitives for error handling
and dependency injection. This is a benefit if you plan to adopt Effect more broadly.
pipe function works identically but comes from a different package.
Replace import { pipe } from 'fp-ts/function' with
import { pipe } from 'effect/Function'. SchemaShift handles this automatically.
# Preview changes with dry run
npx schemashift-cli migrate ./src --from io-ts --to effect --dry-run
# Run the migration
npx schemashift-cli migrate ./src --from io-ts --to effect
# With verbose output and report
npx schemashift-cli migrate ./src --from io-ts --to effect --verbose --report html
# Analyze behavioral differences first
npx schemashift-cli analyze ./src --behavioral io-ts:effect
import * as t from 'io-ts' with import * as S from '@effect/schema/Schema'import { pipe } from 'fp-ts/function' with import { pipe } from 'effect/Function't.type({}) to S.Struct({})t.string → S.String, t.number → S.Numbert.array(X) to S.Array(X)t.union([A, B]) to S.Union(A, B) (spread, not array)t.intersection([A, B]) to pipe(A, S.extend(B))t.TypeOf<typeof X> with S.Schema.Type<typeof X>codec.decode(input) to S.decodeUnknownSync(schema)(input)codec.encode(value) to S.encodeSync(schema)(value)t.recursion() with S.suspend()t.brand() to S.brand()Either, fold)package.json: add @effect/schema and effect, remove io-ts and fp-ts (if unused)npx tsc --noEmit to verify type safetyIf your codebase already uses fp-ts patterns and functional programming, Effect Schema provides a natural upgrade path. It retains the functional paradigm with pipe-based composition, supports encode/decode (not just parse), and integrates with the broader Effect ecosystem for error handling, concurrency, and dependency injection. Choose Zod if you prefer a simpler, more mainstream API.
io-ts uses t.union([A, B]) with array syntax. Effect Schema uses S.Union()
with spread arguments: S.Union(A, B) instead of S.Union([A, B]). This is
a subtle but important difference. For intersections, io-ts t.intersection([A, B])
becomes pipe(A, S.extend(B)) using Effect's pipe function.
io-ts uses codec.decode(input) returning Either. Effect Schema uses
S.decodeUnknownEither(schema)(input) or S.decodeUnknownSync(schema)(input)
for synchronous parsing that throws. The encode direction uses S.encodeSync(schema)(value).
Effect Schema preserves bidirectional encoding that Zod does not support.
SchemaShift automates io-ts to Effect Schema migration with codec conversion, fp-ts pipe rewriting, and inline guidance for complex patterns.
Get SchemaShift