Skip to content

Basic Usage

A schema describes both how to decode unknown input into a typed value and how to encode that value back into its serialized form. Once you have a schema you pick a runner — a function that turns the schema into an actual parser tailored to how you want failures reported (throwing, Result, Effect, and so on).

import { Schema } from "effect"
// The Encoded side stores dates as ISO strings (what arrives over the wire);
// the decoded Type works with real `Date` values.
const Event = Schema.Struct({
title: Schema.String,
startsAt: Schema.DateFromString // Encoded: string -> Type: Date
})
// `decodeUnknownSync` builds a parser for untrusted input and throws on failure.
const event = Schema.decodeUnknownSync(Event)({
title: "Launch",
startsAt: "2026-05-30T10:00:00.000Z"
})
console.log(event.startsAt instanceof Date) // => true
// `encodeUnknownSync` runs the schema in reverse: Type -> Encoded.
const encoded = Schema.encodeUnknownSync(Event)(event)
console.log(encoded)
// => { title: "Launch", startsAt: "2026-05-30T10:00:00.000Z" }

Every schema is a codec parameterized by its decoded Type and its Encoded representation. You can read these off any schema with the Type and Encoded type accessors:

import { Schema } from "effect"
const Event = Schema.Struct({
title: Schema.String,
startsAt: Schema.DateFromString
})
// The value your program works with after decoding.
type Event = typeof Event.Type
// { readonly title: string; readonly startsAt: Date }
// The serialized shape that crosses the boundary.
type EventEncoded = typeof Event.Encoded
// { readonly title: string; readonly startsAt: string }

For a plain Schema.String the two sides coincide (string to string). Schemas like Schema.DateFromString, Schema.NumberFromString, or any custom transformation make the two sides differ — that gap is exactly what decoding and encoding bridge.

A schema actually carries four type parameters. The fully-parameterized base is Bottom, but in everyday code you work through four progressively narrower views — each is just a structural interface that exposes a subset of the parameters:

  • Schema<T> — tracks only the decoded Type. Use it when you accept “any schema that decodes to T” and do not care about the encoded shape.
  • Codec<T, E, RD, RE> — the full picture: decoded Type T, Encoded type E, and the services required during decoding (RD) and encoding (RE).
  • Decoder<T, RD> — a decode-only view (Encoded is unknown). Runner families like decodeUnknown* accept any Decoder.
  • Encoder<E, RE> — an encode-only view (Type is unknown). The encode* families accept any Encoder.
import { Schema } from "effect"
// Accept any schema that decodes to a string, ignoring its encoded shape.
declare function print(schema: Schema.Schema<string>): void
print(Schema.String) // ok
print(Schema.NonEmptyString) // ok
// Accept any codec that decodes to T and encodes to a string.
declare function serialize<T>(codec: Schema.Codec<T, string>): string
serialize(Schema.NumberFromString) // ok — decodes number, encoded as string

Top is the existential “any schema” type — every type parameter erased to unknown. Use it only as the constraint for generic utilities that must accept any schema.

There is also Optic<T, Iso>, a Schema<T> whose decode and encode require no services (both service params are never), enabling lens/prism operations without an Effect runtime. Most primitives implement it automatically.

Widens a schema to the full Codec<T, E, RD, RE> interface so TypeScript infers all four parameters. When a schema is stored in a variable typed as Schema<T>, the encoded type and services are erased; revealCodec recovers them at zero runtime cost.

import { Schema } from "effect"
const schema: Schema.Schema<number> = Schema.NumberFromString
const codec = Schema.revealCodec(schema)
type Enc = typeof codec.Encoded // => string

Widens a schema to the fully-parameterized Bottom interface, exposing all of its type parameters (ast, ~type.make.in, Iso, mutability/optionality, …) for advanced introspection and generic schema utilities.

import { Schema } from "effect"
const bottom = Schema.revealBottom(Schema.String)
type T = typeof bottom.Type // => string
type E = typeof bottom.Encoded // => string

There are two families of runners:

  • decodeUnknown* accept unknown input. Use these at real boundaries where the input is untrusted (HTTP bodies, JSON.parse output, env values).
  • decode* accept input already typed as the schema’s Encoded type. Use these when an upstream layer has already established the encoded shape and you only need the decoding transformations to run.

The same split exists for encoding: encodeUnknown* accept unknown, encode* accept the schema’s decoded Type.

Each runner produces a different result shape so you can match it to the boundary you are at. They all share the same signature shape — decodeUnknownX(schema)(input).

import { Schema, Result } from "effect"
const Port = Schema.Number.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))
// 1. Sync — throws an Error (with the issue in its `cause`) on failure.
const port = Schema.decodeUnknownSync(Port)(8080) // => 8080
// 2. Result — returns Result<number, SchemaIssue.Issue>, no exceptions.
const result = Schema.decodeUnknownResult(Port)(99999)
if (Result.isFailure(result)) {
console.error(String(result.failure)) // human-readable issue
}
// 3. Option — returns Option<number>, discarding the failure details.
const maybe = Schema.decodeUnknownOption(Port)(-1) // => Option.none()

The runner you reach for most in application code is decodeUnknownEffect. It keeps failures in the typed error channel as a SchemaError, and — unlike the sync variants — it can run schemas whose transformations require services or perform asynchronous work.

import { Schema, Effect } from "effect"
const Config = Schema.Struct({
retries: Schema.Number.check(Schema.isGreaterThanOrEqualTo(0)),
endpoint: Schema.String
})
// `Effect.fn` names the operation for tracing and returns an Effect.
const loadConfig = Effect.fn("loadConfig")(function*(raw: unknown) {
// Failures surface as SchemaError in the error channel.
const config = yield* Schema.decodeUnknownEffect(Config)(raw)
yield* Effect.log(`Loaded config for ${config.endpoint}`)
return config
})

Because the failure lives in the error channel, you handle it with the usual error-management combinators (Effect.catch, Effect.catchTag, …) rather than try/catch. The SchemaError wraps the structured issue tree in its .issue field; see error formatting to render it.

By default decoding stops at the first error. Pass { errors: "all" } as a second argument to the runner to collect every issue in one pass — ideal for form validation where you want to show all problems at once.

import { Schema } from "effect"
const Signup = Schema.Struct({
name: Schema.String.check(Schema.isNonEmpty()),
age: Schema.Number.check(Schema.isGreaterThanOrEqualTo(18))
})
// Collect all failures instead of bailing on the first one.
const decode = Schema.decodeUnknownExit(Signup, { errors: "all" })
console.log(String(decode({ name: "", age: 12 })))

Every runner accepts an AST.ParseOptions object — either when the runner is created (decodeUnknownX(schema, options)) or when it is applied (decodeUnknownX(schema)(input, options)). Application options override creation options.

OptionValuesDefaultEffect
errors"first" | "all""first"Stop at the first error, or collect every error.
onExcessProperty"ignore" | "error" | "preserve""ignore"Strip unknown object keys, fail on them, or keep them.
propertyOrder"none" | "original""none"Let the system choose key order, or preserve input order.
disableChecksbooleanfalseSkip validation checks while still applying defaults and transformations.
concurrencynumber | "unbounded"1Max async parse effects to run concurrently.
import { Schema } from "effect"
const User = Schema.Struct({ id: Schema.Number, name: Schema.String })
// Fail when the input carries keys the schema does not declare.
const strict = Schema.decodeUnknownResult(User, { onExcessProperty: "error" })
// Preserve the order in which keys appeared in the input.
const ordered = Schema.decodeUnknownSync(User, { propertyOrder: "original" })

Each direction (decode and encode) comes in two input flavors — Unknown (for unknown input) and the typed variant — and each flavor offers six result shapes. That is 24 runners in total, all with the same call shape runner(schema, options?)(input, options?).

The result shapes are:

  • Sync — returns the value; throws an Error (with the issue in cause) on failure.
  • Effect — returns an Effect; failure is a SchemaError in the error channel. Required services are preserved, and async transformations work.
  • Exit — returns an Exit; failure is a SchemaError in the cause. Runs synchronously.
  • Result — returns a Result; failure is a SchemaIssue.Issue as data.
  • Option — returns an Option; discards failure details.
  • Promise — returns a Promise; rejects with a SchemaIssue.Issue.

A schema and a sample value used by the examples below:

import { Schema } from "effect"
// Encoded: string <-> Type: number
const Count = Schema.NumberFromString

Decodes unknown input synchronously, returning the decoded Type and throwing an Error (with the issue in cause) on failure.

Schema.decodeUnknownSync(Count)("42") // => 42
// Schema.decodeUnknownSync(Count)("x") // throws Error

Decodes unknown input into an Effect<Type, SchemaError, DecodingServices>, preserving service requirements and supporting async transformations.

Schema.decodeUnknownEffect(Count)("42") // => Effect<number, SchemaError>

Decodes unknown input synchronously into Exit<Type, SchemaError>.

Schema.decodeUnknownExit(Count)("42") // => Exit.succeed(42)

Decodes unknown input synchronously into Result<Type, SchemaIssue.Issue>, returning failures as data.

Schema.decodeUnknownResult(Count)("42") // => Result.succeed(42)
Schema.decodeUnknownResult(Count)("x") // => Result.fail(<issue>)

Decodes unknown input synchronously into Option<Type>, discarding failure details.

Schema.decodeUnknownOption(Count)("42") // => Option.some(42)
Schema.decodeUnknownOption(Count)("x") // => Option.none()

Decodes unknown input into a Promise<Type> that rejects with a SchemaIssue.Issue on failure. Only for service-free schemas.

Schema.decodeUnknownPromise(Count)("42") // => Promise<number> resolving to 42

These mirror the Unknown variants but accept input statically typed as the schema’s Encoded type.

Schema.decodeSync(Count)("42") // => 42 (input typed as string)
Schema.decodeEffect(Count)("42") // => Effect<number, SchemaError>
Schema.decodeExit(Count)("42") // => Exit.succeed(42)
Schema.decodeResult(Count)("42") // => Result.succeed(42)
Schema.decodeOption(Count)("42") // => Option.some(42)
Schema.decodePromise(Count)("42") // => Promise<number>

Encoding runs the schema in reverse (Type -> Encoded). The Unknown variants accept unknown input.

Schema.encodeUnknownSync(Count)(42) // => "42"
Schema.encodeUnknownEffect(Count)(42) // => Effect<string, SchemaError>
Schema.encodeUnknownExit(Count)(42) // => Exit.succeed("42")
Schema.encodeUnknownResult(Count)(42) // => Result.succeed("42")
Schema.encodeUnknownOption(Count)(42) // => Option.some("42")
Schema.encodeUnknownPromise(Count)(42) // => Promise<string>

These accept input statically typed as the schema’s decoded Type.

Schema.encodeSync(Count)(42) // => "42"
Schema.encodeEffect(Count)(42) // => Effect<string, SchemaError>
Schema.encodeExit(Count)(42) // => Exit.succeed("42")
Schema.encodeResult(Count)(42) // => Result.succeed("42")
Schema.encodeOption(Count)(42) // => Option.some("42")
Schema.encodePromise(Count)(42) // => Promise<string>

When you do not need a decoded value — only a yes/no answer — use a guard or assertion. These run the schema’s checks against the decoded Type side without producing a value.

Builds a type guard (input) => input is T that returns true/false without exposing issue details, narrowing the input on success.

import { Schema } from "effect"
const isString = Schema.is(Schema.String)
isString("hello") // => true
isString(42) // => false
const value: unknown = "hello"
if (isString(value)) {
console.log(value.toUpperCase()) // => "HELLO" (value narrowed to string)
}

A TypeScript asserts function: returns normally when the input satisfies the schema (narrowing it) and throws when it does not.

import { Schema } from "effect"
const input: unknown = "hello"
Schema.asserts(Schema.String, input)
console.log(input.toUpperCase()) // => "HELLO" (input narrowed)
// Schema.asserts(Schema.String, 123) // throws

Returns true if a value is any schema (a Top). Useful when writing utilities that branch on whether an argument is a schema.

import { Schema } from "effect"
Schema.isSchema(Schema.String) // => true
Schema.isSchema("not a schema") // => false

Narrows an unknown value (e.g. a caught error) to a SchemaError, whose .message renders the issue tree and whose .issue holds the structured tree.

import { Schema } from "effect"
try {
Schema.decodeUnknownSync(Schema.Number)("not a number")
} catch (err) {
if (Schema.isSchemaError(err)) {
console.log(err._tag) // => "SchemaError"
}
}

Every schema exposes a maker family that builds a decoded Type value directly — no decoding/parsing of an encoded representation involved. Makers apply constructor defaults and then run type-side validation.

The input is the schema’s ~type.make.in, which can differ from Type: fields that have a constructor default become optional in make.in. Makers are methods on the schema itself.

import { Effect, Schema } from "effect"
const Person = Schema.Struct({
name: Schema.String,
// gives `age` a constructor default, so it is optional in make input
age: Schema.Number.pipe(Schema.withConstructorDefault(Effect.succeed(0)))
})

Constructs a Type value synchronously, throwing an Error (issue in cause) when validation fails.

Person.make({ name: "Alice" })
// => { name: "Alice", age: 0 } (age default applied)
// Person.make({ name: 123 } as any) // throws Error

Constructs a Type value synchronously, returning Option.some on success or Option.none when validation fails.

Person.makeOption({ name: "Alice" }) // => Option.some({ name: "Alice", age: 0 })

Constructs a Type value inside an Effect, keeping validation failures in the error channel as a SchemaError.

Person.makeEffect({ name: "Alice" })
// => Effect<{ name: string; age: number }, SchemaError>