Skip to content

Default Constructors & Defaults

There are two ways to produce a value that conforms to a schema:

  • Decoding parses external input (Encoded) — JSON from an API, a row from a database — running the full decode pipeline, including transformations.
  • Constructing builds a value in your own code from an already-typed make input. No string-to-number parsing, no key remapping — just validation of the Type side, plus any constructor defaults.

Every schema carries a constructor family — make, makeOption, and makeEffect — and a set of combinators for filling in missing fields. This page covers both: how to construct, and how to attach defaults that fire during construction or during decoding (including defaults computed from an Effect that reads a service).

import { Effect, Schema } from "effect"
const User = Schema.Struct({
name: Schema.String,
// `role` may be omitted from the constructor input; it defaults to "member".
role: Schema.String.pipe(Schema.withConstructorDefault(Effect.succeed("member")))
})
// Construct from a typed value (validates, applies the default):
const user = User.make({ name: "Alice" })
// => { name: "Alice", role: "member" }
// Decode untrusted input (also runs transformations):
const decoded = Schema.decodeUnknownSync(User)({ name: "Bob", role: "admin" })
// => { name: "Bob", role: "admin" }

Each schema exposes three constructors. They all take the schema’s make input (~type.make.in) and differ only in how they report a validation failure.

Constructs synchronously and throws a SchemaError (wrapped in an Error) when validation fails. Best for trusted input, tests, and fixtures.

import { Schema } from "effect"
const Age = Schema.Number.check(Schema.isBetween({ minimum: 0, maximum: 150 }))
Age.make(42)
// => 42
Age.make(200)
// => throws: a value between 0 and 150

Constructs synchronously and returns an OptionOption.some on success, Option.none on failure. Use it when you only care whether construction succeeded and want no error details.

import { Schema, Option } from "effect"
const Age = Schema.Number.check(Schema.isBetween({ minimum: 0, maximum: 150 }))
Age.makeOption(42)
// => Option.some(42)
Age.makeOption(200)
// => Option.none()

Constructs through an Effect, keeping any failure in the error channel as a SchemaError. Reach for this when construction sits inside other effectful code and you want to compose the failure instead of throwing.

import { Effect, Schema } from "effect"
const Age = Schema.Number.check(Schema.isBetween({ minimum: 0, maximum: 150 }))
const program = Age.makeEffect(42)
// program: Effect<number, SchemaError>
Effect.runSync(program)
// => 42

All three accept an optional second argument. disableChecks: true skips validation when you fully trust the input; parseOptions tunes error reporting (e.g. collecting all issues with { errors: "all" }).

import { Schema } from "effect"
const Age = Schema.Number.check(Schema.isBetween({ minimum: 0, maximum: 150 }))
// Skip validation — the out-of-range value is returned as-is.
Age.make(200, { disableChecks: true })
// => 200

The constructor input is not the same as the Type. Fields that are optional, or that carry a constructor default, become optional in the make input. This is the Struct.MakeIn computation: a field marked with-default moves into the optional half of the input object.

import { Effect, Schema } from "effect"
const User = Schema.Struct({
id: Schema.String,
// optional key — omittable in both Type and make input
nickname: Schema.optionalKey(Schema.String),
// has a constructor default — required in Type, optional in make input
role: Schema.String.pipe(Schema.withConstructorDefault(Effect.succeed("member")))
})
// User["Type"] => { readonly id: string; readonly nickname?: string; readonly role: string }
// make input accepts => { readonly id: string; readonly nickname?: string; readonly role?: string }
User.make({ id: "u_1" })
// => { id: "u_1", role: "member" }

withConstructorDefault attaches a default that is applied by make* (and by class constructors) when the field is omitted. The default is an Effect<MakeIn, SchemaError>, so it is lazily evaluated — re-run on every construction — and may itself fail validation.

import { Effect, Schema } from "effect"
const Event = Schema.Struct({
name: Schema.String,
// Re-evaluated on every make — each value gets a fresh array.
tags: Schema.Array(Schema.String).pipe(
Schema.withConstructorDefault(Effect.succeed([]))
)
})
Event.make({ name: "launch" })
// => { name: "launch", tags: [] }
Event.make({ name: "launch", tags: ["release"] })
// => { name: "launch", tags: ["release"] }

Adds a constructor default to a field that does not already have one (enforced by the WithoutConstructorDefault constraint). The field becomes optional in the struct’s make input and is set to with-default in the schema’s type.

import { Effect, Schema } from "effect"
const Config = Schema.Struct({
host: Schema.String,
port: Schema.Number.pipe(Schema.withConstructorDefault(Effect.succeed(8080)))
})
Config.make({ host: "localhost" })
// => { host: "localhost", port: 8080 }
// Defaults are not applied during decoding:
Schema.decodeUnknownSync(Config)({ host: "localhost", port: 80 })
// => { host: "localhost", port: 80 }
// Schema.decodeUnknownSync(Config)({ host: "localhost" }) would FAIL — port is required when decoding.

Constructor defaults are portable: reuse the same field in another struct and the default comes with it.

import { Effect, Schema } from "effect"
const A = Schema.Struct({
count: Schema.Number.pipe(Schema.withConstructorDefault(Effect.succeed(0)))
})
const B = Schema.Struct({
label: Schema.String,
count: A.fields.count // carries the default along
})
B.make({ label: "x" })
// => { label: "x", count: 0 }

These combinators fill a missing field during decoding (and encoding round trips), leaving the constructor untouched. There are four, along two axes:

  • Key-level (...Key) wraps the encoded side with optionalKey: the key may be absent but not undefined. Value-level (no Key) wraps with optional: the key may be absent or explicitly undefined.
  • Encoded-default (withDecodingDefault*) specifies the default as an Encoded value, run through the decode transformation. Type-default (withDecodingDefaultType*) specifies it as a decoded Type value, skipping the transformation.

All four accept DecodingDefaultOptions with encodingStrategy: "passthrough" (default — keep the value when encoding) or "omit" (drop the key when encoding).

Key-level, encoded default: applies the default when the key is absent from the input (not when it is undefined).

import { Effect, Schema } from "effect"
const S = Schema.Struct({
name: Schema.String.pipe(Schema.withDecodingDefaultKey(Effect.succeed("anonymous")))
})
Schema.decodeUnknownSync(S)({})
// => { name: "anonymous" }
Schema.decodeUnknownSync(S)({ name: "Ada" })
// => { name: "Ada" }
// passthrough (default): the value is kept on encode
Schema.encodeUnknownSync(S)({ name: "anonymous" })
// => { name: "anonymous" }

Value-level, encoded default: applies the default when the key is absent or undefined.

import { Effect, Schema } from "effect"
const S = Schema.Struct({
name: Schema.String.pipe(
Schema.optional,
Schema.withDecodingDefault(Effect.succeed("anonymous"))
)
})
Schema.decodeUnknownSync(S)({})
// => { name: "anonymous" }
Schema.decodeUnknownSync(S)({ name: undefined })
// => { name: "anonymous" } (also fires for explicit undefined)
Schema.decodeUnknownSync(S)({ name: "Ada" })
// => { name: "Ada" }

Key-level, Type default: like withDecodingDefaultKey, but the default is given as a decoded Type value, so it does not pass through the decode transformation. Useful when the transformation would be awkward to invert.

import { Effect, Schema } from "effect"
const S = Schema.Struct({
// Encoded is a string, Type is a number.
count: Schema.FiniteFromString.pipe(
Schema.withDecodingDefaultTypeKey(Effect.succeed(0)) // default is a number (Type)
)
})
Schema.decodeUnknownSync(S)({})
// => { count: 0 }
Schema.decodeUnknownSync(S)({ count: "5" })
// => { count: 5 }

Value-level, Type default: like withDecodingDefault, but the default is a decoded Type value, and it fires when the key is absent or undefined.

import { Effect, Schema } from "effect"
const S = Schema.Struct({
count: Schema.FiniteFromString.pipe(
Schema.optional,
Schema.withDecodingDefaultType(Effect.succeed(0))
)
})
Schema.decodeUnknownSync(S)({ count: undefined })
// => { count: 0 }

The decoding-default combinators take an Effect<…, SchemaError, R>, and that R flows into the schema’s DecodingServices. This is new in v4: a default can be produced by an Effect that reads from the environment — a timestamp from Clock, or a value from one of your own services.

Clock is a built-in environment service (not a requirement), so a default that reads it adds nothing to DecodingServices and decodes synchronously:

import { Effect, Schema, Clock } from "effect"
const Message = Schema.Struct({
text: Schema.String,
// When `createdAt` is absent, read the current time from Clock.
createdAt: Schema.Number.pipe(Schema.withDecodingDefaultKey(Clock.currentTimeMillis))
})
// DecodingServices stays `never`; you can decode with the sync API.
Schema.decodeUnknownSync(Message)({ text: "hi" })
// => { text: "hi", createdAt: 1717000000000 } (value depends on the clock)
Schema.decodeUnknownSync(Message)({ text: "hi", createdAt: 1 })
// => { text: "hi", createdAt: 1 }

For your own service, define it with Context.Service (see Services). Now R is non-empty, so the schema gains a DecodingServices requirement: you must decode with the effectful Schema.decodeUnknownEffect and provide the layer before running.

import { Effect, Schema, Context, Layer } from "effect"
// A service that mints sequential ids.
class IdGen extends Context.Service<IdGen, {
readonly next: Effect.Effect<string>
}>()("app/IdGen") {
static readonly layer = Layer.effect(
IdGen,
Effect.sync(() => {
let n = 0
return IdGen.of({ next: Effect.sync(() => `id_${n++}`) })
})
)
}
const Entity = Schema.Struct({
name: Schema.String,
// Reach into the IdGen service, then read its `next` effect.
id: Schema.String.pipe(
Schema.withDecodingDefaultKey(IdGen.use((gen) => gen.next))
)
})
// DecodingServices now includes IdGen, so decoding is an Effect requiring it:
const program = Schema.decodeUnknownEffect(Entity)({ name: "widget" })
// program: Effect<{ name: string; id: string }, SchemaError, IdGen>
Effect.runSync(program.pipe(Effect.provide(IdGen.layer)))
// => { name: "widget", id: "id_0" }

A common use of constructor + decoding defaults is auto-filling a discriminant on a tagged union member.

Builds a Literal schema with a constructor default equal to that literal — so the _tag field can be omitted in make and is auto-filled. Decoding and encoding still require the tag to be present.

import { Schema } from "effect"
const A = Schema.Struct({ _tag: Schema.tag("A"), value: Schema.Number })
A.make({ value: 42 })
// => { _tag: "A", value: 42 }
Schema.decodeUnknownSync(A)({ _tag: "A", value: 42 })
// => { _tag: "A", value: 42 }

Like tag, but combined with withDecodingDefaultKey(..., { encodingStrategy: "omit" }): the _tag is filled during decoding when missing, and omitted from encoded output. Handy for wire formats that don’t carry the discriminant.

import { Schema } from "effect"
const A = Schema.Struct({ _tag: Schema.tagDefaultOmit("A"), value: Schema.Number })
// Decode can omit the tag — it is filled in:
Schema.decodeUnknownSync(A)({ value: 1 })
// => { _tag: "A", value: 1 }
// Encode strips the tag back out:
Schema.encodeUnknownSync(A)({ _tag: "A", value: 1 })
// => { value: 1 }

Shorthand that adds a _tag field via tag automatically. Schema.TaggedStruct("A", { ... }) is equivalent to Schema.Struct({ _tag: Schema.tag("A"), ... }).

import { Schema } from "effect"
const Move = Schema.TaggedStruct("Move", { dx: Schema.Number, dy: Schema.Number })
// _tag optional in make, auto-filled:
Move.make({ dx: 1, dy: 2 })
// => { _tag: "Move", dx: 1, dy: 2 }
// Access the literal:
Move.fields._tag.schema.literal
// => "Move"
APIFires duringSide / absence ruleDefault given as
withConstructorDefaultmake* onlyfield omitted from make inputMakeIn value
withDecodingDefaultKeydecodekey absent (not undefined)Encoded value
withDecodingDefaultdecodekey absent or undefinedEncoded value
withDecodingDefaultTypeKeydecodekey absent (not undefined)Type value
withDecodingDefaultTypedecodekey absent or undefinedType value
tagmake* (fill) + required on decode/encodeliteral
tagDefaultOmitmake* + decode (fill), encode (omit)key absent on decodeliteral