Validation

Type-safe validation with @effect/schema — validate once, trust everywhere

Schema Definitions

Define schemas that serve as both runtime validators and TypeScript types. No duplicate type definitions needed.

import * as S from "@effect/schema/Schema"

// Define a schema
const CreateUser = S.Struct({
  name: S.String.pipe(S.minLength(2), S.maxLength(100)),
  email: S.String.pipe(S.pattern(/@/)),
  age: S.optional(S.Number.pipe(S.int(), S.positive()))
})

// Infer the type
type CreateUser = S.Schema.Type<typeof CreateUser>
// { name: string; email: string; age?: number }

Request Body Validation

import * as HttpServerRequest from "@effect/platform/HttpServerRequest"

HttpRouter.post("/users", Effect.gen(function* () {
  // Validates and decodes — fails with ParseError on bad input
  const body = yield* HttpServerRequest.schemaBodyJson(CreateUser)

  // body is fully typed as CreateUser
  const repo = yield* UserRepo
  const user = yield* repo.create(body)

  return HttpServerResponse.json(user, { status: 201 })
}))

Path Parameters

const UserIdParam = S.Struct({
  id: S.String.pipe(S.pattern(/^[a-f0-9-]{36}$/)) // UUID format
})

HttpRouter.get("/users/:id", Effect.gen(function* () {
  const { id } = yield* HttpRouter.schemaPathParams(UserIdParam)

  const repo = yield* UserRepo
  const user = yield* repo.findById(id)

  if (!user) {
    return HttpServerResponse.empty({ status: 404 })
  }

  return HttpServerResponse.json(user)
}))

Query Parameters

const PaginationQuery = S.Struct({
  page: S.optional(S.NumberFromString).pipe(S.withDefault(() => 1)),
  limit: S.optional(S.NumberFromString).pipe(S.withDefault(() => 20)),
  sort: S.optional(S.Literal("asc", "desc")).pipe(S.withDefault(() => "desc" as const))
})

HttpRouter.get("/users", Effect.gen(function* () {
  const query = yield* HttpRouter.schemaSearchParams(PaginationQuery)
  // query: { page: number; limit: number; sort: "asc" | "desc" }

  const repo = yield* UserRepo
  const users = yield* repo.list(query)

  return HttpServerResponse.json(users)
}))

Response Validation

const UserResponse = S.Struct({
  id: S.String,
  name: S.String,
  email: S.String,
  createdAt: S.Date
})

HttpRouter.get("/users/:id", Effect.gen(function* () {
  const { id } = yield* HttpRouter.schemaPathParams(S.Struct({ id: S.String }))

  const repo = yield* UserRepo
  const user = yield* repo.findById(id)

  if (!user) {
    return HttpServerResponse.empty({ status: 404 })
  }

  // Type-checked: response must match UserResponse schema
  return yield* HttpServerResponse.schemaJson(UserResponse)(user)
}))

Custom Validators

// Email with custom validation
const Email = S.String.pipe(
  S.filter((s) => s.includes("@") && s.includes("."), {
    message: () => "Invalid email format"
  })
)

// Password with strength requirements
const Password = S.String.pipe(
  S.minLength(8),
  S.filter((s) => /[A-Z]/.test(s), {
    message: () => "Must contain uppercase letter"
  }),
  S.filter((s) => /[0-9]/.test(s), {
    message: () => "Must contain number"
  })
)

// Slug format
const Slug = S.String.pipe(
  S.pattern(/^[a-z0-9-]+$/),
  S.minLength(3),
  S.maxLength(50)
)

Transformations

// Transform on decode
const TrimmedString = S.String.pipe(
  S.transform(S.String, {
    decode: (s) => s.trim(),
    encode: (s) => s
  })
)

// Date from ISO string
const DateFromString = S.String.pipe(
  S.transform(S.Date, {
    decode: (s) => new Date(s),
    encode: (d) => d.toISOString()
  })
)

// Or use built-in
import { S.DateFromString } from "@effect/schema/Schema"

Error Handling

HttpRouter.post("/users", Effect.gen(function* () {
  const body = yield* HttpServerRequest.schemaBodyJson(CreateUser)
  // ...
}).pipe(
  Effect.catchTag("ParseError", (e) =>
    HttpServerResponse.json(
      {
        error: "Validation failed",
        details: ArrayFormatter.formatErrorSync(e)
      },
      { status: 400 }
    )
  )
))

Reusable Schemas

// schemas/user.ts
export const UserBase = S.Struct({
  name: S.String.pipe(S.minLength(2)),
  email: Email
})

export const CreateUser = UserBase.pipe(
  S.extend(S.Struct({
    password: Password
  }))
)

export const UpdateUser = S.partial(UserBase)

export const User = UserBase.pipe(
  S.extend(S.Struct({
    id: S.String,
    createdAt: S.Date,
    updatedAt: S.Date
  }))
)