Validate Data with Schema

Define a Schema.Class

import { Schema } from "effect"

export class User extends Schema.Class<User>("User")({
  id: Schema.Number,
  name: Schema.String,
  email: Schema.String.pipe(Schema.pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)),
  role: Schema.Literal("admin", "member", "guest").pipe(Schema.default("guest")),
  age: Schema.Number.pipe(Schema.optionalWith({ default: () => 0 }))
}) {}

Schema.Class defines a domain model with typed fields. Each field maps to a schema that validates input. The class produces both a TypeScript type and a runtime validator from the same definition.

Tip: Schema.Class gives you a constructor, a type, and encoders/decoders in one declaration. You don’t need separate Zod schemas and TypeScript interfaces.

Decode Unknown Input

import { Schema } from "effect"

const rawInput: unknown = {
  id: 1,
  name: "Alice",
  email: "alice@example.com",
  role: "admin"
}

const result = Schema.decodeUnknownSync(User)(rawInput)
// User { id: 1, name: "Alice", email: "alice@example.com", role: "admin", age: 0 }

// Invalid input throws
Schema.decodeUnknownSync(User)({ id: "not a number", name: "Bob", email: "bad" })
// throws: ParseError

decodeUnknownSync takes unknown input and validates it against the schema. Fields with default are filled if missing. Fields with optionalWith become optional in the decoded output.

For async decoding (useful when schemas have async refinements), use Schema.decodeUnknown which returns an Effect:

import { Effect, Schema } from "effect"

const program = Effect.gen(function*() {
  const user = yield* Schema.decodeUnknown(User)(rawInput)
  return user
})

Effect.runPromise(program)

API Request Validation

import { Effect, Schema, Console } from "effect"

class CreateUserRequest extends Schema.Class<CreateUserRequest>("CreateUserRequest")({
  name: Schema.String.pipe(Schema.minLength(1), Schema.maxLength(100)),
  email: Schema.String.pipe(Schema.pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)),
  age: Schema.Number.pipe(Schema.between(0, 150))
}) {}

class ValidationError extends Schema.TaggedError<ValidationError>()("ValidationError", {
  message: Schema.String
}) {}

const validateRequest = (body: unknown) =>
  Effect.gen(function*() {
    const parsed = yield* Schema.decodeUnknown(CreateUserRequest)(body).pipe(
      Effect.catchTag("ParseError", (err) =>
        new ValidationError({ message: err.message })
      )
    )
    return parsed
  })

const handleRequest = (body: unknown) =>
  Effect.gen(function*() {
    const user = yield* validateRequest(body).pipe(
      Effect.catchTag("ValidationError", (err) =>
        Effect.gen(function*() {
          yield* Console.log(`Validation failed: ${err.message}`)
          return null
        })
      )
    )
    if (user) {
      yield* Console.log(`Creating user: ${user.name} <${user.email}>`)
    }
    return user
  })

Effect.runPromise(handleRequest({
  name: "Alice",
  email: "alice@example.com",
  age: 30
}))

Effect.runPromise(handleRequest({
  name: "",
  email: "not-an-email",
  age: 999
}))

Encode to JSON

import { Schema } from "effect"

const user = new User({
  id: 1,
  name: "Alice",
  email: "alice@example.com",
  role: "admin",
  age: 30
})

const json = Schema.encodeJsonSync(User)(user)
// '{"id":1,"name":"Alice","email":"alice@example.com","role":"admin","age":30}'

encodeJsonSync converts a typed instance back to a JSON string. This is the inverse of decode: it takes your domain object and produces the wire format.

For encoding to a plain object (not JSON string), use Schema.encodeSync:

const obj = Schema.encodeSync(User)(user)
// { id: 1, name: "Alice", email: "alice@example.com", role: "admin", age: 30 }

Response Encoding

import { Effect, Schema, Console } from "effect"

class UserResponse extends Schema.Class<UserResponse>("UserResponse")({
  id: Schema.Number,
  name: Schema.String,
  email: Schema.String,
  role: Schema.String
}) {}

const createUser = Effect.fn("createUser")(
  function*(input: { name: string; email: string }) {
    // ... save to database ...
    const user = new UserResponse({
      id: Math.floor(Math.random() * 1000),
      name: input.name,
      email: input.email,
      role: "member"
    })

    const json = yield* Schema.encodeJson(UserResponse)(user)
    return json
  }
)

Effect.runPromise(createUser({ name: "Bob", email: "bob@example.com" }))
  .then(Console.log)
// '{"id":42,"name":"Bob","email":"bob@example.com","role":"member"}'

Custom Transformations

import { Schema, Effect } from "effect"

// Transform a date string to a Date object on decode, and back to string on encode
const DateFromString = Schema.String.pipe(
  Schema.transform(
    Schema.Date,
    {
      decode: (s) => new Date(s),
      encode: (d) => d.toISOString()
    }
  )
)

class Event extends Schema.Class<Event>("Event")({
  name: Schema.String,
  timestamp: DateFromString
}) {}

const decoded = Schema.decodeUnknownSync(Event)({
  name: "Meeting",
  timestamp: "2025-01-15T10:00:00.000Z"
})
// Event { name: "Meeting", timestamp: Date(2025-01-15T10:00:00.000Z) }

const encoded = Schema.encodeJsonSync(Event)(decoded)
// '{"name":"Meeting","timestamp":"2025-01-15T10:00:00.000Z"}'

Schema.transform bridges two schemas with custom decode and encode functions. Use it when your internal representation differs from the wire format.

Filters and Refinements

import { Schema, Effect } from "effect"

class PositiveInt extends Schema.Class<PositiveInt>("PositiveInt")({
  value: Schema.Number.pipe(
    Schema.int(),
    Schema.positive()
  )
}) {}

// Valid
const valid = Schema.decodeUnknownSync(PositiveInt)({ value: 42 })
// PositiveInt { value: 42 }

// Invalid - not an integer
Schema.decodeUnknownSync(PositiveInt)({ value: 3.14 })
// throws: ParseError

// Invalid - not positive
Schema.decodeUnknownSync(PositiveInt)({ value: -5 })
// throws: ParseError

Filters like Schema.int(), Schema.positive(), Schema.minLength(n), and Schema.between(a, b) add constraints that are checked during decoding.

Next Steps