Skip to content

Structs and Records

Most real schemas are objects. Schema.Struct describes an object with a fixed set of known keys; Schema.Record describes a dictionary with a uniform key and value type. Around them sit arrays and tuples for ordered collections.

import { Schema } from "effect"
// A struct with required, optional, and constrained fields.
const User = Schema.Struct({
id: Schema.String,
name: Schema.String.check(Schema.isMinLength(1)),
// Exact optional: the key may be omitted entirely, but not set to undefined.
email: Schema.optionalKey(Schema.String),
// A nested array of literals.
roles: Schema.Array(Schema.Literals(["admin", "member"]))
})
const user = Schema.decodeUnknownSync(User)({
id: "u_1",
name: "Alice",
roles: ["admin"]
})
console.log(user)
// { id: "u_1", name: "Alice", roles: ["admin"] }

A struct decodes an object key by key. By default every field is required and the resulting type is readonly. The decoded Type and Encoded types are derived from the field schemas:

import { Schema } from "effect"
const Point = Schema.Struct({
x: Schema.Number,
y: Schema.Number
})
type Point = typeof Point.Type
// { readonly x: number; readonly y: number }

There are two distinct notions of “optional”, and the difference matters:

  • Schema.optionalKey(S)exact optional. The key may be absent, but if present it must satisfy S. The type becomes key?: T.
  • Schema.optional(S) — the key may be absent or explicitly undefined. The type becomes key?: T | undefined. (It is shorthand for optionalKey(UndefinedOr(S)).)
import { Schema } from "effect"
const Profile = Schema.Struct({
name: Schema.String,
// absent OK, undefined NOT OK
nickname: Schema.optionalKey(Schema.String),
// absent OK, undefined also OK
bio: Schema.optional(Schema.String)
})
type Profile = typeof Profile.Type
// {
// readonly name: string
// readonly nickname?: string
// readonly bio?: string | undefined
// }

To supply a value when a key is absent during decoding, wrap the field with Schema.withDecodingDefaultKey. The default is provided as an Effect, so it can be computed (and can even use services).

import { Effect, Schema } from "effect"
const Settings = Schema.Struct({
// When `theme` is missing from the input, decode it as "light".
theme: Schema.String.pipe(
Schema.withDecodingDefaultKey(Effect.succeed("light"))
)
})
console.log(Schema.decodeUnknownSync(Settings)({}))
// { theme: "light" }

Struct fields are readonly by default. Use Schema.mutableKey to drop the readonly modifier on a single field:

import { Schema } from "effect"
const Counter = Schema.Struct({
label: Schema.String,
// produces `count: number` instead of `readonly count: number`
count: Schema.mutableKey(Schema.Number)
})

Schema.Record describes a dictionary: an object whose keys all share one schema and whose values all share another. Use it when the keys are not known ahead of time.

import { Schema } from "effect"
// { readonly [x: string]: number }
const Scores = Schema.Record(Schema.String, Schema.Number)
const scores = Schema.decodeUnknownSync(Scores)({ alice: 10, bob: 7 })
console.log(scores)
// { alice: 10, bob: 7 }

Schema.Array describes a ReadonlyArray of a single element schema. Schema.NonEmptyArray additionally requires at least one element and narrows the type to a non-empty tuple.

import { Schema } from "effect"
const Tags = Schema.Array(Schema.String)
type Tags = typeof Tags.Type // readonly string[]
const Coordinates = Schema.NonEmptyArray(Schema.Number)
type Coordinates = typeof Coordinates.Type // readonly [number, ...number[]]

Schema.Tuple describes a fixed-length, positionally-typed array. To allow additional trailing elements of a uniform type, use Schema.TupleWithRest.

import { Schema } from "effect"
// Exactly [string, number]
const Pair = Schema.Tuple([Schema.String, Schema.Number])
type Pair = typeof Pair.Type // readonly [string, number]
// [string, ...number[]] — a label followed by any number of values
const Row = Schema.TupleWithRest(
Schema.Tuple([Schema.String]),
[Schema.Number]
)

When you have several object shapes distinguished by a tag, Schema.TaggedUnion builds the union and the matching helpers in one step. Each key becomes the _tag literal of that variant.

import { Schema } from "effect"
const Shape = Schema.TaggedUnion({
Circle: { radius: Schema.Number },
Rectangle: { width: Schema.Number, height: Schema.Number }
})
// `match` is generated for you — exhaustive over the variants.
const area = Shape.match(
{ _tag: "Circle", radius: 5 },
{
Circle: (c) => Math.PI * c.radius ** 2,
Rectangle: (r) => r.width * r.height
}
)
console.log(area.toFixed(2)) // "78.54"

This pairs naturally with pattern matching and is the recommended way to model serializable variant data.

A schema that refers to itself must defer the self-reference with Schema.suspend, otherwise the definition would loop forever while being built.

import { Schema } from "effect"
interface Category {
readonly name: string
readonly subcategories: ReadonlyArray<Category>
}
const Category = Schema.Struct({
name: Schema.String,
// `suspend` delays evaluating `Category` until it is actually needed.
subcategories: Schema.Array(
Schema.suspend((): Schema.Codec<Category> => Category)
)
})

With shapes in hand, the next step is constraining the values inside them with filters.