Classes
Effect Schema lets you define TypeScript classes that are automatically validated. This combines the convenience of classes with the safety of schema validation.
Why Schema Classes?
Traditional classes have no runtime validation:
typescript
// ❌ No validation - any values accepted
class User {
constructor(public name: string, public age: number) {}
}
const user = new User("", -5) // Invalid but allowed!Schema classes validate on construction:
typescript
import { Schema } from "effect"
class User extends Schema.Class<User>("User")({
name: Schema.String.pipe(Schema.nonEmptyString()),
age: Schema.Number.pipe(Schema.int(), Schema.between(0, 150))
}) {}
const user = new User({ name: "Alice", age: 30 }) // ✅ Validated
const bad = new User({ name: "", age: -5 }) // ❌ Throws!Defining Schema Classes
Basic Class
typescript
import { Schema } from "effect"
class Person extends Schema.Class<Person>("Person")({
name: Schema.String,
age: Schema.Number
}) {}
// Use like a regular class
const person = new Person({ name: "Alice", age: 30 })
console.log(person.name) // "Alice"
console.log(person.age) // 30
// It's also a schema!
Schema.decodeUnknownSync(Person)({ name: "Bob", age: 25 })The generic parameter Schema.Class<Person> enables proper TypeScript inference for the Self type.
With Validation
typescript
import { Schema } from "effect"
class Email extends Schema.Class<Email>("Email")({
value: Schema.String.pipe(
Schema.pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)
)
}) {
get domain(): string {
return this.value.split("@")[1]
}
}
const email = new Email({ value: "alice@example.com" })
console.log(email.domain) // "example.com"
new Email({ value: "invalid" }) // ❌ Throws ParseErrorWith Methods
Add methods to your schema classes:
typescript
import { Schema } from "effect"
class Rectangle extends Schema.Class<Rectangle>("Rectangle")({
width: Schema.Number.pipe(Schema.positive()),
height: Schema.Number.pipe(Schema.positive())
}) {
get area(): number {
return this.width * this.height
}
get perimeter(): number {
return 2 * (this.width + this.height)
}
scale(factor: number): Rectangle {
return new Rectangle({
width: this.width * factor,
height: this.height * factor
})
}
}
const rect = new Rectangle({ width: 10, height: 5 })
console.log(rect.area) // 50
console.log(rect.perimeter) // 30
const scaled = rect.scale(2)
console.log(scaled.area) // 200Extending Classes
Extend Schema Classes
typescript
import { Schema } from "effect"
// Base class
class Entity extends Schema.Class<Entity>("Entity")({
id: Schema.String,
createdAt: Schema.DateFromString
}) {}
// Extended class
class User extends Entity.extend<User>("User")({
name: Schema.String,
email: Schema.String
}) {}
const user = new User({
id: "1",
createdAt: "2024-01-15T10:30:00Z",
name: "Alice",
email: "alice@example.com"
})
// user.createdAt is a Date!
console.log(user.createdAt.getFullYear()) // 2024Inherit Methods
typescript
import { Schema } from "effect"
class Entity extends Schema.Class<Entity>("Entity")({
id: Schema.String,
createdAt: Schema.DateFromString
}) {
get age(): number {
return Date.now() - this.createdAt.getTime()
}
}
class User extends Entity.extend<User>("User")({
name: Schema.String
}) {
greet(): string {
return `Hello, ${this.name}!`
}
}
const user = new User({
id: "1",
createdAt: "2024-01-15T10:30:00Z",
name: "Alice"
})
console.log(user.age) // Inherited method
console.log(user.greet()) // "Hello, Alice!"Optional Fields
Use Schema.optional for optional class properties:
typescript
import { Schema } from "effect"
class User extends Schema.Class<User>("User")({
id: Schema.String,
name: Schema.String,
email: Schema.optional(Schema.String),
phone: Schema.optional(Schema.String, { default: () => "N/A" })
}) {}
// email is optional
const user1 = new User({ id: "1", name: "Alice" })
console.log(user1.email) // undefined
console.log(user1.phone) // "N/A" (default applied)
// email provided
const user2 = new User({
id: "2",
name: "Bob",
email: "bob@example.com"
})Constructor Defaults
Provide default values for construction:
typescript
import { Schema } from "effect"
class Settings extends Schema.Class<Settings>("Settings")({
theme: Schema.optional(Schema.String).pipe(
Schema.withConstructorDefault(() => "light")
),
fontSize: Schema.optional(Schema.Number).pipe(
Schema.withConstructorDefault(() => 14)
)
}) {}
// Defaults applied during construction
const settings = new Settings({})
console.log(settings.theme) // "light"
console.log(settings.fontSize) // 14
// Can override defaults
const custom = new Settings({ theme: "dark" })
console.log(custom.theme) // "dark"
console.log(custom.fontSize) // 14make() Method
Schema classes provide a static make method:
typescript
import { Schema } from "effect"
class User extends Schema.Class<User>("User")({
name: Schema.String,
age: Schema.Number
}) {}
// Both are equivalent
const user1 = new User({ name: "Alice", age: 30 })
const user2 = User.make({ name: "Alice", age: 30 })Using as Schema
Schema classes are schemas themselves:
typescript
import { Schema } from "effect"
class User extends Schema.Class<User>("User")({
name: Schema.String,
age: Schema.Number
}) {}
// Use in other schemas
const Team = Schema.Struct({
name: Schema.String,
members: Schema.Array(User)
})
// Decode returns User instances
const team = Schema.decodeUnknownSync(Team)({
name: "Engineering",
members: [
{ name: "Alice", age: 30 },
{ name: "Bob", age: 25 }
]
})
// team.members[0] is a User instance
console.log(team.members[0] instanceof User) // true
console.log(team.members[0].name) // "Alice"Tagged Classes
Use TaggedClass for discriminated unions:
typescript
import { Schema } from "effect"
class Circle extends Schema.TaggedClass<Circle>()("Circle", {
radius: Schema.Number.pipe(Schema.positive())
}) {
get area(): number {
return Math.PI * this.radius ** 2
}
}
class Rectangle extends Schema.TaggedClass<Rectangle>()("Rectangle", {
width: Schema.Number.pipe(Schema.positive()),
height: Schema.Number.pipe(Schema.positive())
}) {
get area(): number {
return this.width * this.height
}
}
// Union of tagged classes
const Shape = Schema.Union(Circle, Rectangle)
type Shape = typeof Shape.Type
function getArea(shape: Shape): number {
switch (shape._tag) {
case "Circle":
return shape.area // shape is narrowed to Circle
case "Rectangle":
return shape.area // shape is narrowed to Rectangle
}
}
const circle = new Circle({ radius: 5 })
console.log(circle._tag) // "Circle"
console.log(circle.area) // ~78.54Error Classes
Create tagged error classes:
typescript
import { Schema } from "effect"
class ValidationError extends Schema.TaggedError<ValidationError>()(
"ValidationError",
{
field: Schema.String,
message: Schema.String
}
) {}
class NotFoundError extends Schema.TaggedError<NotFoundError>()(
"NotFoundError",
{
resource: Schema.String,
id: Schema.String
}
) {
get fullMessage(): string {
return `${this.resource} with id ${this.id} not found`
}
}
// These are proper Error instances
const error = new ValidationError({
field: "email",
message: "Invalid format"
})
console.log(error instanceof Error) // true
console.log(error._tag) // "ValidationError"
throw error // Can be thrownTransformations in Classes
Classes can include transformed fields:
typescript
import { Schema } from "effect"
class User extends Schema.Class<User>("User")({
name: Schema.String,
birthDate: Schema.DateFromString, // string → Date
role: Schema.Literal("admin", "user")
}) {
get age(): number {
const now = new Date()
const diff = now.getTime() - this.birthDate.getTime()
return Math.floor(diff / (365.25 * 24 * 60 * 60 * 1000))
}
}
// Decode from JSON
const user = Schema.decodeUnknownSync(User)({
name: "Alice",
birthDate: "1990-05-15T00:00:00Z",
role: "admin"
})
console.log(user.birthDate instanceof Date) // true
console.log(user.age) // Calculated from Date
// Encode back to JSON
const json = Schema.encodeSync(User)(user)
console.log(json.birthDate) // "1990-05-15T00:00:00.000Z" (string)Practical Examples
Entity Base Class
typescript
import { Schema } from "effect"
class Entity extends Schema.Class<Entity>("Entity")({
id: Schema.String.pipe(Schema.nonEmptyString()),
createdAt: Schema.DateFromString,
updatedAt: Schema.DateFromString
}) {}
class User extends Entity.extend<User>("User")({
email: Schema.String,
name: Schema.String,
role: Schema.Literal("admin", "member", "guest")
}) {
isAdmin(): boolean {
return this.role === "admin"
}
}
class Post extends Entity.extend<Post>("Post")({
title: Schema.String,
content: Schema.String,
authorId: Schema.String,
published: Schema.Boolean
}) {
get excerpt(): string {
return this.content.slice(0, 100) + "..."
}
}Money with Currency
typescript
import { Schema } from "effect"
class Money extends Schema.Class<Money>("Money")({
amount: Schema.Number.pipe(Schema.finite()),
currency: Schema.Literal("USD", "EUR", "GBP")
}) {
add(other: Money): Money {
if (this.currency !== other.currency) {
throw new Error("Cannot add different currencies")
}
return new Money({
amount: this.amount + other.amount,
currency: this.currency
})
}
format(): string {
const symbols = { USD: "$", EUR: "€", GBP: "£" }
return `${symbols[this.currency]}${this.amount.toFixed(2)}`
}
}
const price = new Money({ amount: 99.99, currency: "USD" })
console.log(price.format()) // "$99.99"Summary
| Pattern | Use Case |
|---|---|
Schema.Class<T>() | Basic schema class |
.extend<T>() | Extend existing schema class |
Schema.TaggedClass<T>() | Discriminated union member |
Schema.TaggedError<T>() | Error class |
make() | Alternative constructor |
Next Steps
- Brands - Branded types for type safety
- Transformations - Type conversions
- JSON Schema - Generate JSON Schema from definitions