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.

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)
// 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)

The runner you reach for most in application code is decodeUnknownEffect. It keeps failures in the typed error channel as a SchemaIssue.Issue, 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 SchemaIssue.Issue 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.

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 })))

When you do not need a decoded value — only a yes/no answer — use Schema.is for a type guard or Schema.asserts for an assertion. These run the schema’s checks without producing the structured issue tree.

import { Schema } from "effect"
const NonEmpty = Schema.String.check(Schema.isNonEmpty())
const isNonEmpty = Schema.is(NonEmpty)
if (isNonEmpty("hello")) {
// `"hello"` is narrowed to the schema's Type here.
}