03·02Schema Classes

`Schema.Class`, `Schema.TaggedClass`, and `Schema.TaggedErrorClass` are the same definition playing three roles at once: a TypeScript class, a `Schema` that decodes and encodes instances, and a `Data` value with structural equality. One declaration covers the

19 - Schema Classes: Domain Models with a Schema Built-In

Previous · Next

Schema.Class, Schema.TaggedClass, and Schema.TaggedErrorClass are the same definition playing three roles at once: a TypeScript class, a Schema that decodes and encodes instances, and a Data value with structural equality. One declaration covers the type, the validator, and the runtime constructor.

What you’ll work through

index.test.ts contains four beats: constructing a Schema.Class, decoding and encoding round-trips, the tagged-class variant for discriminated unions, and Schema.TaggedErrorClass for typed failures.

By the end you should reach for Schema.Class for any domain model that crosses an I/O boundary, and Schema.TaggedErrorClass for any failure that needs both validation and exhaustive matching.


Three Things in One Declaration

A Schema.Class declaration looks like a class but ships with everything Effect’s other modules expect.

class Person extends Schema.Class<Person>("Person")({ ... fields ... }) {
// ▲ ▲
// │ └── Schema for decode/encode
// └── Real TypeScript class, instanceof works, methods allowed
}
Equal.equals(a, b) // structural, inherited from Data.Class
new Person({ ... }) // construct directly
Person.make({ ... }) // factory variant
Schema.decodeUnknownSync(Person) // decode from unknown into a Person instance
Schema.encodeSync(Person) // encode a Person back to a plain object

Schema.TaggedClass adds a _tag field; Schema.TaggedErrorClass does the same and also extends Error, so the value is throwable and survives stack-trace machinery.


Schema.Class

Creates a class whose constructor accepts the field record and whose schema decodes unknown input into an instance of the class.

import { Schema } from "effect"
class Person extends Schema.Class<Person>("Person")({
id: Schema.Number,
name: Schema.NonEmptyString,
age: Schema.Number,
}) {
get displayName(): string {
return `${this.name} (${this.age})`
}
}
// ┌─── Person
// ▼
const alice = new Person({ id: 1, name: "Alice", age: 30 })
alice instanceof Person // true
alice.displayName // "Alice (30)"

Details

The single type parameter (Schema.Class<Person>("Person")) ties the schema to the class itself, which is how methods declared in the class body remain visible on decoded instances. The string argument ("Person") is the identifier used in error messages and serialised AST.

When to Use

When the type benefits from methods, getters, or instanceof, a domain entity whose behaviour belongs with its data. For pure shapes without behaviour, Schema.Struct is lighter.


Decoding and Encoding

A Schema.Class is a Schema<Self, FieldsEncoded>, so the standard decode/encode helpers work without ceremony.

import { Schema } from "effect"
class Person extends Schema.Class<Person>("Person")({
id: Schema.Number,
name: Schema.NonEmptyString,
age: Schema.Number,
}) {}
const raw = { id: 1, name: "Alice", age: 30 }
// ┌─── Person
// ▼
const decoded = Schema.decodeUnknownSync(Person)(raw)
decoded instanceof Person // true
// ┌─── { readonly id: number; readonly name: string; readonly age: number }
// ▼
const encoded = Schema.encodeSync(Person)(decoded)

Details

Schema.NonEmptyString is a refinement; decoding { name: "" } fails with a ParseError. Every other field constraint behaves the same way.

The class identifier appears in the error message so failures are easy to locate.


Schema.TaggedClass

Adds a _tag field whose value matches the tag identifier. Instances participate in Match.tag, Effect.catchTag, and any other API keyed on _tag.

import { Schema } from "effect"
class SuccessResult extends Schema.TaggedClass<SuccessResult>()("SuccessResult", {
value: Schema.Number,
}) {}
class FailureResult extends Schema.TaggedClass<FailureResult>()("FailureResult", {
message: Schema.String,
}) {}
// ┌─── SuccessResult
// ▼
const ok = new SuccessResult({ value: 42 })
ok._tag // "SuccessResult"

When to Use

For members of a discriminated union that also need to be decoded, for example, a response body whose tag selects between alternative shapes.


Schema.TaggedErrorClass

A Schema.TaggedClass that also extends Error. The result is a typed failure suitable for the E channel and the standard recovery operators.

import { Effect, Schema } from "effect"
class ValidationError extends Schema.TaggedErrorClass<ValidationError>()(
"ValidationError",
{ field: Schema.String, message: Schema.String },
) {}
// ┌─── Effect<never, ValidationError, never>
// ▼
const program = Effect.fail(
new ValidationError({ field: "email", message: "invalid format" }),
)
// ┌─── Effect<string, never, never>
// ▼
const recovered = program.pipe(
Effect.catchTag("ValidationError", (e) =>
Effect.succeed(`${e.field}: ${e.message}`),
),
)

Details

Because the class extends Error, the instance has a message property and a stack trace. The _tag is set from the identifier, which is what Effect.catchTag matches on.

When to Use

As the standard typed error in new code. Schema.TaggedErrorClass supersedes Data.TaggedError when you also want decode/encode support, for instance, surfacing the error in an HTTP response body.


A Full Example: A Decoded Domain Entity

import { Effect, Schema } from "effect"
class User extends Schema.Class<User>("User")({
id: Schema.Number,
email: Schema.String,
role: Schema.Literal("admin", "member"),
}) {
get isAdmin(): boolean {
return this.role === "admin"
}
}
class UserNotFound extends Schema.TaggedErrorClass<UserNotFound>()("UserNotFound", {
id: Schema.Number,
}) {}
// ┌─── (raw: unknown) => Effect<User, UserNotFound | ParseError>
// ▼
const decodeUser = (raw: unknown) =>
Schema.decodeUnknownEffect(User)(raw)

The decoded user is a real User instance, instanceof works, user.isAdmin works, and Equal.equals(user1, user2) follows structural rules.


Common Pitfalls

1, Passing a plain object where the class is expected. A Schema.Class instance is a class. { id: 1, name: "Alice", age: 30 } is not a Person, even if the fields match.

Use new Person({...}) or Schema.decodeUnknownSync(Person)(raw).

2, Omitting the self type parameter. Schema.Class<Person>("Person")(...) is required; the self parameter is what ties the schema’s output to the class. Forgetting it produces a confusing “missing self generic” type error.

3, Treating Schema.Class and Data.TaggedClass as interchangeable. Both produce Equal-compatible classes. The difference is the schema attached to Schema.Class.

If the value never needs to be decoded from untrusted input, Data.TaggedClass is lighter.

4, Decoding before construction inside a constructor. Decode at the boundary, then pass the validated value down. Embedding decodeUnknownSync inside a method runs the schema on every call and obscures where validation happens.


same definition serves both directions. - [ ] Define a `Schema.TaggedClass` for a discriminated union variant. - [ ] Define a `Schema.TaggedErrorClass` and place an instance in the `E` channel.

See Also

  • Previous, 16-branded-types: phantom-type IDs and refined primitives.
  • Next, 20-schema-transformations: bidirectional encode/decode pipelines.
  • Related, 18-schema-modeling: the broader schema vocabulary.
  • Related, 15-data: Schema.Class extends Data.Class.
  • Related, 04-error-types: typed errors and recovery.

Run the tests: pnpm test 19-schema-classes


The full test suite

The index.test.ts for this lesson, verbatim from steps/19-schema-classes/.

index.test.ts
import { describe, it, expect } from "vite-plus/test"
import { Effect, Equal, Schema } from "effect"
// =============================================================================
// 48 - Schema Classes: Domain Models with a Schema Built-In
// =============================================================================
//
// Schema.Class produces a TypeScript class, a Schema, and a Data value at once.
// Schema.TaggedClass adds a _tag field. Schema.TaggedErrorClass adds a _tag and
// extends Error so the value works in the Effect failure channel.
class Person extends Schema.Class<Person>("Person")({
id: Schema.Number,
name: Schema.NonEmptyString,
age: Schema.Number,
}) {
get displayName(): string {
return `${this.name} (${this.age})`
}
}
describe("48 - Schema Classes: Domain Models with a Schema Built-In", () => {
// @beat
// id: schema-class-construct
// shape: one-todo
// title: Build a domain class with Schema.Class
// teaches: Schema.Class<Self>("Name")({ fields }) returns a class with constructor, .make factory, and methods on the body.
// hint: new Person({ id, name, age }) — fields land as instance properties
describe("Schema.Class construction", () => {
it("creates an instance via the constructor", () => {
const person = /* @stub */ new Person({ id: 1, name: "Alice", age: 30 }) /* @end */
expect(person.id).toBe(1)
expect(person.name).toBe("Alice")
expect(person.age).toBe(30)
})
it("supports the .make factory", () => {
const person = Person.make({ id: 1, name: "Alice", age: 30 })
expect(person.name).toBe("Alice")
})
it("retains methods declared on the class body", () => {
const person = new Person({ id: 1, name: "Alice", age: 30 })
expect(person.displayName).toBe("Alice (30)")
})
})
// @end-beat
describe("Schema.Class structural equality", () => {
it("compares two instances by their fields", () => {
const a = new Person({ id: 1, name: "Alice", age: 30 })
const b = new Person({ id: 1, name: "Alice", age: 30 })
expect(Equal.equals(a, b)).toBe(true)
})
it("returns false when any field differs", () => {
const a = new Person({ id: 1, name: "Alice", age: 30 })
const b = new Person({ id: 2, name: "Bob", age: 25 })
expect(Equal.equals(a, b)).toBe(false)
})
})
// @beat
// id: schema-class-decode-encode
// shape: one-todo
// title: Decode and encode a Schema.Class
// teaches: Schema.decodeUnknownSync produces a real class instance; Schema.encodeSync returns the plain object form.
// hint: Schema.decodeUnknownSync(Person)(raw) returns a Person
describe("Schema.Class decoding", () => {
it("decodes unknown input into a class instance", () => {
const raw = { id: 1, name: "Alice", age: 30 }
// ┌─── Person
// ▼
const person = /* @stub */ Schema.decodeUnknownSync(Person)(raw) /* @end */
expect(person).toBeInstanceOf(Person)
expect(person.displayName).toBe("Alice (30)")
})
it("fails when a field has the wrong type", () => {
const raw = { id: "not-a-number", name: "Alice", age: 30 }
expect(() => Schema.decodeUnknownSync(Person)(raw)).toThrow()
})
it("fails when a refinement is violated", () => {
expect(() =>
Schema.decodeUnknownSync(Person)({ id: 1, name: "", age: 30 }),
).toThrow()
})
})
describe("Schema.Class encoding", () => {
it("encodes an instance back to a plain object", () => {
const person = new Person({ id: 1, name: "Alice", age: 30 })
const encoded = /* @stub */ Schema.encodeSync(Person)(person) /* @end */
expect(encoded).toEqual({ id: 1, name: "Alice", age: 30 })
})
})
// @end-beat
// @beat
// id: schema-tagged-class
// shape: one-todo
// title: Tag a class for discriminated unions
// teaches: Schema.TaggedClass<Self>()("Tag", { fields }) adds a _tag field that Match.tag and Effect.catchTag consume.
// hint: class SuccessResult extends Schema.TaggedClass<SuccessResult>()("SuccessResult", {...}) {}
describe("Schema.TaggedClass", () => {
class SuccessResult extends /* @stub */ Schema.TaggedClass<SuccessResult>()(
"SuccessResult",
{ value: Schema.Number },
) /* @end */ {}
class FailureResult extends Schema.TaggedClass<FailureResult>()(
"FailureResult",
{ message: Schema.String },
) {}
it("sets _tag from the identifier", () => {
const ok = new SuccessResult({ value: 42 })
expect(ok._tag).toBe("SuccessResult")
})
it("narrows in a discriminated union", () => {
const result: SuccessResult | FailureResult = new SuccessResult({
value: 1,
})
if (result._tag === "SuccessResult") {
expect(result.value).toBe(1)
}
})
})
// @end-beat
// @beat
// id: schema-tagged-error
// shape: one-todo
// title: Define a typed failure with Schema.TaggedErrorClass
// teaches: Schema.TaggedErrorClass extends Error and carries a _tag, so Effect.fail and Effect.catchTag work directly.
// hint: Effect.catchTag("ValidationError", (e) => Effect.succeed(`${e.field}: ${e.message}`))
describe("Schema.TaggedErrorClass", () => {
class ValidationError extends Schema.TaggedErrorClass<ValidationError>()(
"ValidationError",
{ field: Schema.String, message: Schema.String },
) {}
it("works as a typed Effect failure", async () => {
const program = Effect.fail(
new ValidationError({ field: "email", message: "invalid format" }),
)
const exit = await Effect.runPromiseExit(program)
expect(exit._tag).toBe("Failure")
})
it("is recoverable via Effect.catchTag", async () => {
const result = await Effect.runPromise(
/* @stub */ Effect.fail(
new ValidationError({ field: "name", message: "required" }),
).pipe(
Effect.catchTag("ValidationError", (e) =>
Effect.succeed(`${e.field}: ${e.message}`),
),
) /* @end */,
)
expect(result).toBe("name: required")
})
})
// @end-beat
})
← PreviousSchema ModelingNext →Schema Transformations