Skip to content

Classes & Opaque Types

Schema.Class turns a struct schema into a class. You get validated construction, a named type you can attach methods to, and — because the class extends Effect’s Data — value-based equality out of the box. It is the idiomatic way to model a domain entity that is more than a bag of fields.

import { Schema } from "effect"
// `class Self extends Schema.Class<Self>("Identifier")({ fields }) {}`
class Person extends Schema.Class<Person>("Person")({
firstName: Schema.String.check(Schema.isNonEmpty()),
lastName: Schema.String.check(Schema.isNonEmpty()),
age: Schema.Number.check(Schema.isGreaterThanOrEqualTo(0))
}) {
// Regular methods and getters work as you would expect.
get fullName(): string {
return `${this.firstName} ${this.lastName}`
}
}
// The constructor validates its input against the schema.
const alice = new Person({ firstName: "Alice", lastName: "Ng", age: 30 })
console.log(alice.fullName) // => "Alice Ng"
console.log(`${alice}`) // => "Person({ firstName: Alice, lastName: Ng, age: 30 })"

A plain Schema.Struct produces anonymous objects. A Schema.Class gives you:

  • Validated constructionnew Person({...}) runs the schema’s checks and throws on invalid input, so an instance is always valid.
  • Identity — the type has a name (Person), useful in errors, logs, and pattern matching.
  • Behaviour — methods and getters live alongside the data.
  • Value equality — two instances with equal fields are Equal.equals.

The class is a schema, so you can decode and encode through it just like any other schema.

import { Schema } from "effect"
class Person extends Schema.Class<Person>("Person")({
name: Schema.String,
age: Schema.Number
}) {}
// Decode unknown input straight into a Person instance.
const person = Schema.decodeUnknownSync(Person)({ name: "Bob", age: 25 })
console.log(person instanceof Person) // => true

Because the class extends Data.Class, instances compare by value rather than by reference. This makes class instances safe to use as keys in a HashMap, members of a HashSet, or anywhere Effect relies on structural equality (see Equal & Hash).

import { Equal, Schema } from "effect"
class Point extends Schema.Class<Point>("Point")({
x: Schema.Number,
y: Schema.Number
}) {}
const a = new Point({ x: 1, y: 2 })
const b = new Point({ x: 1, y: 2 })
console.log(Equal.equals(a, b)) // => true — same field values
console.log(a === b) // => false — different references

new Person({...}) throws on invalid input, which is fine inside trusted code where the inputs are statically known. When you would rather keep failures in the error channel, use the static makeEffect — it returns an Effect that fails with a SchemaError.

import { Effect, Schema } from "effect"
class Person extends Schema.Class<Person>("Person")({
name: Schema.String.check(Schema.isNonEmpty()),
age: Schema.Number.check(Schema.isGreaterThanOrEqualTo(0))
}) {}
const program = Effect.gen(function*() {
// Validation failures surface as a typed SchemaError, not a thrown exception.
const person = yield* Person.makeEffect({ name: "Carol", age: 41 })
yield* Effect.log(`Created ${person.name}`)
return person
})

Schema.TaggedClass adds a _tag literal field automatically, so instances participate in tagged-union pattern matching. Note the extra () — the Self generic is supplied first, then the tag and fields are passed at the next call.

import { Schema } from "effect"
class Circle extends Schema.TaggedClass<Circle>()("Circle", {
radius: Schema.Number
}) {}
const c = new Circle({ radius: 5 })
console.log(c._tag) // => "Circle"
console.log(c.radius) // => 5

The _tag is auto-populated as a constructor default, so you never pass it. Combine several tagged classes into a Schema.Union and switch on _tag:

import { Schema } from "effect"
class Circle extends Schema.TaggedClass<Circle>()("Circle", {
radius: Schema.Number
}) {}
class Square extends Schema.TaggedClass<Square>()("Square", {
side: Schema.Number
}) {}
const Shape = Schema.Union([Circle, Square])
function area(shape: typeof Shape.Type): number {
switch (shape._tag) {
case "Circle":
return Math.PI * shape.radius ** 2
case "Square":
return shape.side ** 2
}
}
console.log(area(new Circle({ radius: 2 }))) // => 12.566...
console.log(area(new Square({ side: 3 }))) // => 9

Every Schema.Class exposes a static extend to derive a subclass with extra fields, inheriting the parent’s fields, checks, and methods.

import { Schema } from "effect"
class Animal extends Schema.Class<Animal>("Animal")({
name: Schema.String
}) {}
class Dog extends Animal.extend<Dog>("Dog")({
breed: Schema.String
}) {}
const dog = new Dog({ name: "Rex", breed: "Labrador" })
console.log(dog.name) // => "Rex"
console.log(dog.breed) // => "Labrador"

Schema.Class<Self>(identifier)(fieldsOrStruct, annotations?) produces a class whose constructor validates input and whose instances carry value identity. You pass either a plain fields object or an existing Schema.Struct.

import { Schema } from "effect"
// From a fields object
class A extends Schema.Class<A>("A")({ n: Schema.Number }) {}
// From an existing Struct (reuse a struct schema you already have)
const Fields = Schema.Struct({ n: Schema.Number })
class B extends Schema.Class<B>("B")(Fields) {}
console.log(new A({ n: 1 }).n) // => 1
console.log(new B({ n: 2 }).n) // => 2

The class is a full schema — decode and encode through it directly:

import { Schema } from "effect"
class Person extends Schema.Class<Person>("Person")({
name: Schema.String,
age: Schema.Number
}) {}
const decode = Schema.decodeUnknownSync(Person)
console.log(decode({ name: "Bob", age: 25 }) instanceof Person) // => true
const encode = Schema.encodeUnknownSync(Person)
console.log(encode(new Person({ name: "Bob", age: 25 })))
// => { name: "Bob", age: 25 }

new Self(props, options?) validates props against the schema and throws on failure. Pass { disableChecks: true } in options to skip validation when you trust the data.

import { Schema } from "effect"
class Age extends Schema.Class<Age>("Age")({
value: Schema.Number.check(Schema.isGreaterThanOrEqualTo(0))
}) {}
console.log(new Age({ value: 30 }).value) // => 30
// Bypass validation for trusted data:
console.log(new Age({ value: -1 }, { disableChecks: true }).value) // => -1

Static make(props, options?) is the schema-style constructor, equivalent to new Self(...). It throws on invalid input and applies constructor defaults.

import { Schema } from "effect"
class Person extends Schema.Class<Person>("Person")({
name: Schema.String
}) {}
console.log(Person.make({ name: "Alice" }).name) // => "Alice"

Static makeOption(props, options?) returns an Option<Self>Some on success, None on validation failure — instead of throwing.

import { Option, Schema } from "effect"
class Age extends Schema.Class<Age>("Age")({
value: Schema.Number.check(Schema.isGreaterThanOrEqualTo(0))
}) {}
console.log(Option.isSome(Age.makeOption({ value: 5 }))) // => true
console.log(Age.makeOption({ value: -1 })) // => none()

Static makeEffect(props, options?) returns Effect<Self, SchemaError>, moving validation failures into the error channel.

import { Effect, Schema } from "effect"
class Age extends Schema.Class<Age>("Age")({
value: Schema.Number.check(Schema.isGreaterThanOrEqualTo(0))
}) {}
const program = Effect.gen(function*() {
const age = yield* Age.makeEffect({ value: 5 })
return age.value // => 5
})

Static fields exposes the field schemas, so you can reuse or recompose them when building other schemas.

import { Schema } from "effect"
class Person extends Schema.Class<Person>("Person")({
name: Schema.String,
age: Schema.Number
}) {}
// Reuse a subset of the class's fields in another struct.
const NameOnly = Schema.Struct({ name: Person.fields.name })
console.log(Object.keys(Person.fields)) // => ["name", "age"]

Static identifier returns the string identifier the class was created with.

import { Schema } from "effect"
class Person extends Schema.Class<Person>("Person")({ name: Schema.String }) {}
console.log(Person.identifier) // => "Person"

Static extend<Extended>(identifier)(newFields, annotations?) derives a subclass that inherits the parent’s fields, checks, and methods, and adds the new fields. New fields with the same key override the parent’s.

import { Schema } from "effect"
class Animal extends Schema.Class<Animal>("Animal")({
name: Schema.String
}) {
greet() {
return `Hi, I'm ${this.name}`
}
}
class Dog extends Animal.extend<Dog>("Dog")({
breed: Schema.String
}) {}
const dog = new Dog({ name: "Rex", breed: "Labrador" })
console.log(dog.greet()) // => "Hi, I'm Rex" (inherited method)
console.log(dog.breed) // => "Labrador"

Static mapFields(f, options?) returns a new Struct (not a class) whose fields are derived from the class’s fields by f. Useful for composing a new schema from an existing class’s shape.

import { Schema, Struct } from "effect"
class Person extends Schema.Class<Person>("Person")({
name: Schema.String,
age: Schema.Number
}) {}
// Make every field optional in a derived struct. `Schema.optional` is a Struct
// lambda, so `Struct.map` applies it to each field.
const PartialPerson = Person.mapFields((fields) =>
Struct.map(fields, Schema.optional)
)
console.log(Object.keys(PartialPerson.fields)) // => ["name", "age"]

The class inherits the standard schema combinators. annotate and annotateKey attach annotations and return a rebuilt schema; check appends a refinement that validates the constructed instance.

import { Schema } from "effect"
class Range extends Schema.Class<Range>("Range")({
min: Schema.Number,
max: Schema.Number
}) {}
// Add an instance-level invariant.
const ValidRange = Range.check(
Schema.makeFilter((r) => (r.min <= r.max ? undefined : "min must be <= max"))
)
console.log(Schema.is(ValidRange)(new Range({ min: 1, max: 2 }))) // => true
console.log(Schema.is(ValidRange)(new Range({ min: 5, max: 2 }))) // => false

Fields built with Schema.withConstructorDefault supply a value when omitted from make/new. Defaults apply only during construction, never during decoding or encoding.

import { Effect, Schema } from "effect"
class Config extends Schema.Class<Config>("Config")({
retries: Schema.Number.pipe(
Schema.optionalKey,
Schema.withConstructorDefault(Effect.succeed(3))
)
}) {}
console.log(new Config({}).retries) // => 3
console.log(new Config({ retries: 5 }).retries) // => 5

Schema.TaggedClass<Self>()(tag, fields, annotations?) is Class plus an auto-populated _tag literal. The optional first call supplies Self; the tag string both names the schema (by default) and becomes the _tag value.

import { Schema } from "effect"
class Loaded extends Schema.TaggedClass<Loaded>()("Loaded", {
data: Schema.String
}) {}
const l = new Loaded({ data: "ok" })
console.log(l._tag) // => "Loaded"
// Decode preserves the tag.
console.log(Schema.decodeUnknownSync(Loaded)({ _tag: "Loaded", data: "ok" })._tag)
// => "Loaded"

Schema.Opaque<Self>()(schema) wraps a schema (typically a Struct) so its decoded Type becomes the nominal type Self, without changing the runtime shape. Unlike Class, instances are plain objects — there is no constructor, no methods, and no _tag; you just get a named, opaque type alias backed by the struct.

import { Schema } from "effect"
class User extends Schema.Opaque<User>()(
Schema.Struct({
id: Schema.Number,
name: Schema.String
})
) {}
// Decoded value is `User`, but at runtime it is a plain object.
const user = Schema.decodeUnknownSync(User)({ id: 1, name: "Alice" })
console.log(user) // => { id: 1, name: "Alice" }
console.log(user instanceof User) // => false (Opaque has no instances)

Because the opaque value’s type is Self, you can refer to it by name and read its fields directly off the schema (it inherits the struct’s fields, make, etc.):

import { Schema } from "effect"
class User extends Schema.Opaque<User>()(
Schema.Struct({ id: Schema.Number, name: Schema.String })
) {}
// `make` comes from the underlying struct.
const u = User.make({ id: 2, name: "Bob" })
console.log(u.name) // => "Bob"
// Field schemas are accessible too.
console.log(Object.keys(User.fields)) // => ["id", "name"]

Schema.asClass(schema) turns any schema (not just structs) into a class-like constructor so you can extends it and attach static members that reference this. The added construct signature is new(_: never): {} — it is not meant to be instantiated; it exists so you can hang statics and a name on a schema.

import { Schema } from "effect"
class MyString extends Schema.asClass(Schema.String) {
static readonly decodeUnknownSync = Schema.decodeUnknownSync(this)
}
console.log(MyString.decodeUnknownSync("a")) // => "a"

Schema.ErrorClass / Schema.TaggedErrorClass

Section titled “Schema.ErrorClass / Schema.TaggedErrorClass”

Schema.ErrorClass and Schema.TaggedErrorClass build on Class (TaggedErrorClass also adds a _tag) and additionally implement Effect’s yieldable error interface, so instances can be yield*-ed and matched with Effect.catchTag. These are documented in full on the defining errors page.

import { Schema } from "effect"
class NotFound extends Schema.TaggedErrorClass<NotFound>()("NotFound", {
id: Schema.Number
}) {}
console.log(new NotFound({ id: 1 })._tag) // => "NotFound"
  • Defining errorsErrorClass and TaggedErrorClass for schema-backed, yieldable errors.
  • Data — plain value classes (Data.Class, Data.TaggedClass) when you do not need a schema.
  • Equal & Hash — the structural-equality traits that Schema.Class instances participate in.