Schema.Struct vs Schema.Class: моделирование сущностей
Schema.Struct — структуры данных, вложенные структуры, optional поля. Schema.Class — классы с идентичностью, номинальная типизация, методы, Smart Constructors. TaggedStruct и TaggedClass. Наследование через extend. Рекурсивные типы. Когда Struct, а когда Class.
Введение: от примитивов к составным типам
В предыдущей главе мы научились создавать схемы для примитивных типов. Но доменные модели — это составные структуры: Entity, Value Object, DTO. Для их описания Effect Schema предоставляет два основных инструмента:
Schema.Struct— описывает структуру данных (product type)Schema.Class— описывает класс с идентичностью и поведением
Выбор между ними — архитектурное решение, которое напрямую влияет на дизайн доменной модели.
Schema.Struct — структура данных
Основы
Schema.Struct создаёт схему для объекта с фиксированным набором полей:
import { Schema } from "effect"
const Person = Schema.Struct({
name: Schema.String,
age: Schema.Number.pipe(Schema.int(), Schema.nonNegative()),
email: Schema.String
})
// Автоматическое извлечение типов
type Person = typeof Person.Type
// { readonly name: string; readonly age: number; readonly email: string }
type PersonEncoded = typeof Person.Encoded
// { readonly name: string; readonly age: number; readonly email: string }
// Decode
const person = Schema.decodeUnknownSync(Person)({
name: "Alice",
age: 30,
email: "alice@example.com"
})
// person: Person
Обратите внимание: все поля автоматически readonly. Schema по умолчанию создаёт иммутабельные структуры — это полностью соответствует принципам функционального программирования.
Вложенные структуры
import { Schema } from "effect"
const Address = Schema.Struct({
street: Schema.String,
city: Schema.String,
zip: Schema.String.pipe(Schema.pattern(/^\d{5}$/))
})
const Company = Schema.Struct({
name: Schema.String,
address: Address, // вложенная Schema
founded: Schema.DateFromString
})
type Company = typeof Company.Type
// {
// readonly name: string
// readonly address: { readonly street: string; readonly city: string; readonly zip: string }
// readonly founded: Date
// }
type CompanyEncoded = typeof Company.Encoded
// {
// readonly name: string
// readonly address: { readonly street: string; readonly city: string; readonly zip: string }
// readonly founded: string ← Date трансформировался в string!
// }
Optional поля
import { Schema } from "effect"
const Todo = Schema.Struct({
id: Schema.String,
title: Schema.String,
// ═══════════════════════════════════════
// Варианты optional
// ═══════════════════════════════════════
// 1. optional — поле может отсутствовать
description: Schema.optional(Schema.String),
// Type: string | undefined, поле может отсутствовать в объекте
// 2. optional с exact: true
notes: Schema.optional(Schema.String, { exact: true }),
// Строго: поле может быть undefined, но не missing
// 3. optional с default
completed: Schema.optional(Schema.Boolean, { default: () => false }),
// Type: boolean (NOT optional!) — default делает поле обязательным в Type
// Encoded: boolean | undefined — при decode missing/undefined → false
// 4. optional с nullable
dueDate: Schema.optional(Schema.DateFromString, { nullable: true }),
// Type: Date | undefined
// Encoded: string | null | undefined
// 5. optionalWith as: "Option" — превращает в Option<T>
assignee: Schema.optional(Schema.String, { as: "Option" }),
// Type: Option<string> — Option.none() если отсутствует
// Encoded: string | undefined
})
Разберём optional с default подробнее — это очень полезный паттерн:
import { Schema } from "effect"
const Config = Schema.Struct({
host: Schema.optional(Schema.String, { default: () => "localhost" }),
port: Schema.optional(Schema.Number, { default: () => 3000 }),
debug: Schema.optional(Schema.Boolean, { default: () => false }),
})
type Config = typeof Config.Type
// { readonly host: string; readonly port: number; readonly debug: boolean }
// Все поля required! Default убирает optional на уровне Type
// Можно декодировать пустой объект — все дефолты подставятся
const config = Schema.decodeUnknownSync(Config)({})
// { host: "localhost", port: 3000, debug: false }
// Или переопределить часть
const customConfig = Schema.decodeUnknownSync(Config)({ port: 8080 })
// { host: "localhost", port: 8080, debug: false }
Struct с дополнительными полями
По умолчанию Schema.Struct отбрасывает лишние поля при декодировании (что безопасно):
import { Schema } from "effect"
const User = Schema.Struct({
name: Schema.String,
age: Schema.Number
})
// Лишние поля отбрасываются
const user = Schema.decodeUnknownSync(User)({
name: "Alice",
age: 30,
extra: "ignored" // это поле будет отброшено
})
// user = { name: "Alice", age: 30 }
Если нужно сохранить дополнительные поля:
// Record позволяет сохранить дополнительные поля
const Extensible = Schema.Struct(
{ name: Schema.String }, // обязательные поля
Schema.Record({ key: Schema.String, value: Schema.Unknown }) // дополнительные
)
Schema.Class — классы с идентичностью
Основы Schema.Class
Schema.Class создаёт настоящий TypeScript class с конструктором, instanceof, и методами:
import { Schema } from "effect"
class Todo extends Schema.Class<Todo>("Todo")({
id: Schema.String,
title: Schema.String.pipe(Schema.nonEmptyString()),
completed: Schema.Boolean,
createdAt: Schema.DateFromString
}) {}
// Todo — это класс
const todo = new Todo({
id: "1",
title: "Buy milk",
completed: false,
createdAt: new Date()
})
// instanceof работает
console.log(todo instanceof Todo) // true
// Поля readonly
todo.title // "Buy milk"
// todo.title = "x" // ❌ Error: Cannot assign to read-only property
// Тип извлекается
type TodoType = typeof Todo.Type
// Todo (экземпляр класса)
// Encoded тип
type TodoEncoded = typeof Todo.Encoded
// { readonly id: string; readonly title: string; readonly completed: boolean; readonly createdAt: string }
Ключевое отличие: номинальная типизация
Schema.Struct создаёт структурный тип — два Struct с одинаковыми полями совместимы:
import { Schema } from "effect"
const PointA = Schema.Struct({ x: Schema.Number, y: Schema.Number })
const PointB = Schema.Struct({ x: Schema.Number, y: Schema.Number })
type A = typeof PointA.Type // { readonly x: number; readonly y: number }
type B = typeof PointB.Type // { readonly x: number; readonly y: number }
// A и B — ОДИН И ТОТ ЖЕ тип! TypeScript считает их совместимыми
Schema.Class создаёт номинальный тип — два класса с одинаковыми полями различны:
import { Schema } from "effect"
class CartesianPoint extends Schema.Class<CartesianPoint>("CartesianPoint")({
x: Schema.Number,
y: Schema.Number
}) {}
class PolarPoint extends Schema.Class<PolarPoint>("PolarPoint")({
x: Schema.Number, // r (radius)
y: Schema.Number // θ (angle)
}) {}
// CartesianPoint и PolarPoint — РАЗНЫЕ типы!
const cartesian = new CartesianPoint({ x: 3, y: 4 })
const polar = new PolarPoint({ x: 5, y: 0.93 })
function distanceFromOrigin(p: CartesianPoint): number {
return Math.sqrt(p.x ** 2 + p.y ** 2)
}
distanceFromOrigin(cartesian) // ✅ OK
// distanceFromOrigin(polar) // ❌ Type error! PolarPoint не CartesianPoint
Это критически важно для доменного моделирования: UserId и OrderId — это разные сущности, даже если оба содержат string.
Методы в Schema.Class
Schema.Class позволяет добавлять методы — поведение сущности:
import { Schema, Option } from "effect"
class Todo extends Schema.Class<Todo>("Todo")({
id: Schema.String,
title: Schema.String.pipe(Schema.nonEmptyString()),
completed: Schema.Boolean,
priority: Schema.Literal("low", "medium", "high"),
dueDate: Schema.optional(Schema.DateFromString, { as: "Option" })
}) {
// ═══════════════════════════════════════
// Методы — поведение сущности
// ═══════════════════════════════════════
/** Завершить задачу */
complete(): Todo {
return new Todo({ ...this, completed: true })
}
/** Изменить приоритет */
withPriority(priority: "low" | "medium" | "high"): Todo {
return new Todo({ ...this, priority })
}
/** Изменить заголовок */
withTitle(title: string): Todo {
return new Todo({ ...this, title })
}
/** Просрочена ли задача */
isOverdue(now: Date): boolean {
return Option.match(this.dueDate, {
onNone: () => false,
onSome: (due) => !this.completed && due < now
})
}
/** Является ли высокоприоритетной */
get isHighPriority(): boolean {
return this.priority === "high"
}
}
// Использование
const todo = new Todo({
id: "1",
title: "Deploy to production",
completed: false,
priority: "high",
dueDate: Option.some(new Date("2024-12-31"))
})
const completed = todo.complete()
// completed — новый Todo с completed: true
// todo — неизменён (immutable!)
console.log(completed.completed) // true
console.log(todo.completed) // false — оригинал не изменился
Важно: методы не мутируют объект, а возвращают новый экземпляр. Это иммутабельный подход, полностью соответствующий функциональному стилю.
Статические методы и фабрики
import { Schema, Effect } from "effect"
class Todo extends Schema.Class<Todo>("Todo")({
id: Schema.String,
title: Schema.String.pipe(Schema.nonEmptyString(), Schema.maxLength(255)),
completed: Schema.Boolean,
createdAt: Schema.DateFromString
}) {
/** Фабричный метод: создать новую задачу */
static create(params: {
readonly id: string
readonly title: string
}): Todo {
return new Todo({
...params,
completed: false,
createdAt: new Date()
})
}
/** Безопасная фабрика через Effect */
static createSafe(params: {
readonly id: string
readonly title: string
}): Effect.Effect<Todo, Schema.ParseError> {
return Schema.decode(Todo)({
...params,
completed: false,
createdAt: new Date()
})
}
complete(): Todo {
return new Todo({ ...this, completed: true })
}
}
// Использование фабрики
const todo = Todo.create({ id: "1", title: "Buy milk" })
// todo.completed === false
// todo.createdAt === now
Когда Struct, а когда Class?
Это ключевой вопрос проектирования. Вот чёткие критерии:
Используйте Schema.Struct когда:
✅ Value Objects (без идентичности)
✅ DTO (Data Transfer Objects)
✅ Промежуточные структуры (маппинг БД)
✅ Конфигурация
✅ Параметры запросов / ответы
import { Schema } from "effect"
// Value Object — нет идентичности, только значение
const Money = Schema.Struct({
amount: Schema.BigDecimalFromNumber,
currency: Schema.Literal("USD", "EUR", "RUB")
})
// DTO — структура для передачи данных
const CreateTodoInput = Schema.Struct({
title: Schema.String.pipe(Schema.nonEmptyString()),
priority: Schema.optional(Schema.Literal("low", "medium", "high"), {
default: () => "medium" as const
}),
dueDate: Schema.optional(Schema.DateFromString)
})
// Маппинг строки БД
const TodoRow = Schema.Struct({
id: Schema.String,
title: Schema.String,
completed: Schema.Number,
created_at: Schema.String,
updated_at: Schema.NullOr(Schema.String)
})
// Конфигурация
const AppConfig = Schema.Struct({
host: Schema.String,
port: Schema.Number.pipe(Schema.int(), Schema.between(1, 65535)),
debug: Schema.Boolean
})
Используйте Schema.Class когда:
✅ Entity (объект с идентичностью)
✅ Aggregate Root (корень агрегата)
✅ Объекты с поведением (методы)
✅ Объекты, требующие instanceof
✅ Tagged union members (через _tag)
import { Schema, Option } from "effect"
// Entity — объект с идентичностью (id)
class Todo extends Schema.Class<Todo>("Todo")({
id: Schema.String,
title: Schema.String.pipe(Schema.nonEmptyString()),
status: Schema.Literal("draft", "active", "completed", "archived"),
createdAt: Schema.DateFromString
}) {
activate(): Todo {
if (this.status !== "draft") {
throw new Error(`Cannot activate todo in status: ${this.status}`)
}
return new Todo({ ...this, status: "active" as const })
}
complete(): Todo {
if (this.status !== "active") {
throw new Error(`Cannot complete todo in status: ${this.status}`)
}
return new Todo({ ...this, status: "completed" as const })
}
}
// Domain Event — Tagged Class
class TodoCreated extends Schema.TaggedClass<TodoCreated>()("TodoCreated", {
todoId: Schema.String,
title: Schema.String,
createdAt: Schema.DateFromString
}) {}
class TodoCompleted extends Schema.TaggedClass<TodoCompleted>()("TodoCompleted", {
todoId: Schema.String,
completedAt: Schema.DateFromString
}) {}
Сводная таблица
| Критерий | Schema.Struct | Schema.Class |
|---|---|---|
| Типизация | Структурная | Номинальная (instanceof) |
| Методы | Нет | Да |
| Наследование | Нет | Через extend |
| _tag (discriminator) | Вручную | Через TaggedClass |
| Иммутабельность | По умолчанию readonly | По умолчанию readonly |
| Идентичность | Нет | Да |
| Использование | VO, DTO, конфиг | Entity, Aggregate, Event |
| Вес | Легковесный | Тяжелее (class) |
Schema.TaggedStruct и Schema.TaggedClass
TaggedStruct — дискриминированный Struct
import { Schema } from "effect"
// TaggedStruct добавляет _tag автоматически
const Circle = Schema.TaggedStruct("Circle", {
radius: Schema.Number.pipe(Schema.positive())
})
type Circle = typeof Circle.Type
// { readonly _tag: "Circle"; readonly radius: number }
const Rectangle = Schema.TaggedStruct("Rectangle", {
width: Schema.Number.pipe(Schema.positive()),
height: Schema.Number.pipe(Schema.positive())
})
// Удобно для discriminated unions
const Shape = Schema.Union(Circle, Rectangle)
type Shape = typeof Shape.Type
// { _tag: "Circle"; radius: number } | { _tag: "Rectangle"; width: number; height: number }
TaggedClass — дискриминированный Class
import { Schema } from "effect"
// TaggedClass — РЕКОМЕНДОВАННЫЙ подход для Domain Events
class TodoCreated extends Schema.TaggedClass<TodoCreated>()("TodoCreated", {
todoId: Schema.String,
title: Schema.String,
createdAt: Schema.DateFromString
}) {}
class TodoCompleted extends Schema.TaggedClass<TodoCompleted>()("TodoCompleted", {
todoId: Schema.String,
completedAt: Schema.DateFromString
}) {}
class TodoArchived extends Schema.TaggedClass<TodoArchived>()("TodoArchived", {
todoId: Schema.String,
archivedAt: Schema.DateFromString
}) {}
// Объединение в union событий
const TodoEvent = Schema.Union(TodoCreated, TodoCompleted, TodoArchived)
type TodoEvent = typeof TodoEvent.Type
// TodoCreated | TodoCompleted | TodoArchived
// Pattern matching по _tag
const describeEvent = (event: TodoEvent): string => {
switch (event._tag) {
case "TodoCreated":
return `Todo "${event.title}" created`
case "TodoCompleted":
return `Todo ${event.todoId} completed`
case "TodoArchived":
return `Todo ${event.todoId} archived`
}
}
// instanceof работает
const event: TodoEvent = new TodoCreated({
todoId: "1",
title: "Buy milk",
createdAt: new Date()
})
if (event instanceof TodoCreated) {
console.log(event.title) // TypeScript знает, что это TodoCreated
}
Наследование и extend
Extend для Struct
import { Schema } from "effect"
// Базовая структура
const BaseEntity = Schema.Struct({
id: Schema.String,
createdAt: Schema.DateFromString,
updatedAt: Schema.DateFromString
})
// Расширение — добавление полей
const Todo = Schema.extend(
BaseEntity,
Schema.Struct({
title: Schema.String.pipe(Schema.nonEmptyString()),
completed: Schema.Boolean,
priority: Schema.Literal("low", "medium", "high")
})
)
type Todo = typeof Todo.Type
// {
// readonly id: string
// readonly createdAt: Date
// readonly updatedAt: Date
// readonly title: string
// readonly completed: boolean
// readonly priority: "low" | "medium" | "high"
// }
Extend для Class
import { Schema } from "effect"
// Базовый класс
class BaseEntity extends Schema.Class<BaseEntity>("BaseEntity")({
id: Schema.String,
createdAt: Schema.DateFromString,
updatedAt: Schema.DateFromString
}) {
get age(): number {
return Date.now() - this.createdAt.getTime()
}
}
// Наследование через extend
class Todo extends BaseEntity.extend<Todo>("Todo")({
title: Schema.String.pipe(Schema.nonEmptyString()),
completed: Schema.Boolean
}) {
complete(): Todo {
return new Todo({
...this,
completed: true,
updatedAt: new Date()
})
}
}
const todo = new Todo({
id: "1",
title: "Buy milk",
completed: false,
createdAt: new Date(),
updatedAt: new Date()
})
// Методы базового класса доступны
console.log(todo.age)
// И методы наследника
const done = todo.complete()
Паттерн “Smart Constructor” через Schema.Class
“Smart Constructor” — это фабрика, которая гарантирует валидность создаваемого объекта. Schema.Class идеально подходит для этого:
import { Schema, Effect } from "effect"
// ═══════════════════════════════════════
// Доменные типы-значения
// ═══════════════════════════════════════
const TodoTitle = Schema.String.pipe(
Schema.nonEmptyString(),
Schema.maxLength(255),
Schema.brand("TodoTitle")
)
type TodoTitle = typeof TodoTitle.Type
const TodoId = Schema.String.pipe(
Schema.nonEmptyString(),
Schema.brand("TodoId")
)
type TodoId = typeof TodoId.Type
// ═══════════════════════════════════════
// Entity через Schema.Class
// ═══════════════════════════════════════
class Todo extends Schema.Class<Todo>("Todo")({
id: TodoId,
title: TodoTitle,
completed: Schema.Boolean,
createdAt: Schema.DateFromString
}) {
// Smart Constructor — единственный способ создать Todo
static create(
id: TodoId,
title: TodoTitle
): Todo {
return new Todo({
id,
title,
completed: false,
createdAt: new Date()
})
}
// Безопасный Smart Constructor (с валидацией через Effect)
static fromRaw(raw: {
readonly id: string
readonly title: string
}): Effect.Effect<Todo, Schema.ParseError> {
return Effect.gen(function* () {
const id = yield* Schema.decode(TodoId)(raw.id)
const title = yield* Schema.decode(TodoTitle)(raw.title)
return Todo.create(id, title)
})
}
complete(): Todo {
return new Todo({ ...this, completed: true })
}
rename(newTitle: TodoTitle): Todo {
return new Todo({ ...this, title: newTitle })
}
}
Рекурсивные типы
Для моделирования деревьев, вложенных комментариев, каталогов:
import { Schema } from "effect"
// Рекурсивная структура: категория с подкатегориями
interface Category {
readonly name: string
readonly children: ReadonlyArray<Category>
}
const Category: Schema.Schema<Category> = Schema.suspend(() =>
Schema.Struct({
name: Schema.String,
children: Schema.Array(Category)
})
)
// Использование
const decoded = Schema.decodeUnknownSync(Category)({
name: "Root",
children: [
{
name: "Work",
children: [
{ name: "Urgent", children: [] },
{ name: "Backlog", children: [] }
]
},
{
name: "Personal",
children: []
}
]
})
Практический пример: полная доменная модель Todo
Соберём все знания вместе:
import { Schema, Option } from "effect"
// ═══════════════════════════════════════
// 1. Branded Value Types (Struct-based)
// ═══════════════════════════════════════
const TodoId = Schema.String.pipe(Schema.nonEmptyString(), Schema.brand("TodoId"))
type TodoId = typeof TodoId.Type
const TodoTitle = Schema.Trim.pipe(
Schema.nonEmptyString(),
Schema.maxLength(255),
Schema.brand("TodoTitle")
)
type TodoTitle = typeof TodoTitle.Type
// ═══════════════════════════════════════
// 2. Enumerations (Literal-based)
// ═══════════════════════════════════════
const TodoStatus = Schema.Literal("draft", "active", "completed", "archived")
type TodoStatus = typeof TodoStatus.Type
const Priority = Schema.Literal("low", "medium", "high", "critical")
type Priority = typeof Priority.Type
// ═══════════════════════════════════════
// 3. Value Objects (Struct-based)
// ═══════════════════════════════════════
const TodoMetadata = Schema.Struct({
tags: Schema.Array(Schema.String.pipe(Schema.nonEmptyString())).pipe(
Schema.maxItems(20)
),
source: Schema.optional(Schema.Literal("web", "api", "import"), {
default: () => "web" as const
})
})
type TodoMetadata = typeof TodoMetadata.Type
// ═══════════════════════════════════════
// 4. Entity (Class-based)
// ═══════════════════════════════════════
class Todo extends Schema.Class<Todo>("Todo")({
id: TodoId,
title: TodoTitle,
status: TodoStatus,
priority: Priority,
dueDate: Schema.optional(Schema.DateFromString, { as: "Option" }),
metadata: TodoMetadata,
createdAt: Schema.DateFromString,
updatedAt: Schema.DateFromString
}) {
static create(params: {
readonly id: TodoId
readonly title: TodoTitle
readonly priority?: Priority
}): Todo {
const now = new Date()
return new Todo({
id: params.id,
title: params.title,
status: "draft",
priority: params.priority ?? "medium",
dueDate: Option.none(),
metadata: { tags: [], source: "web" },
createdAt: now,
updatedAt: now
})
}
activate(): Todo {
return new Todo({ ...this, status: "active", updatedAt: new Date() })
}
complete(): Todo {
return new Todo({ ...this, status: "completed", updatedAt: new Date() })
}
archive(): Todo {
return new Todo({ ...this, status: "archived", updatedAt: new Date() })
}
withPriority(priority: Priority): Todo {
return new Todo({ ...this, priority, updatedAt: new Date() })
}
withDueDate(date: Date): Todo {
return new Todo({ ...this, dueDate: Option.some(date), updatedAt: new Date() })
}
isOverdue(now: Date): boolean {
return Option.match(this.dueDate, {
onNone: () => false,
onSome: (due) => this.status === "active" && due < now
})
}
}
// ═══════════════════════════════════════
// 5. Domain Events (TaggedClass-based)
// ═══════════════════════════════════════
class TodoCreated extends Schema.TaggedClass<TodoCreated>()("TodoCreated", {
todoId: TodoId,
title: TodoTitle,
priority: Priority,
occurredAt: Schema.DateFromString
}) {}
class TodoStatusChanged extends Schema.TaggedClass<TodoStatusChanged>()("TodoStatusChanged", {
todoId: TodoId,
from: TodoStatus,
to: TodoStatus,
occurredAt: Schema.DateFromString
}) {}
const TodoEvent = Schema.Union(TodoCreated, TodoStatusChanged)
type TodoEvent = typeof TodoEvent.Type
// ═══════════════════════════════════════
// 6. DTOs (Struct-based)
// ═══════════════════════════════════════
const CreateTodoInput = Schema.Struct({
title: Schema.String.pipe(Schema.nonEmptyString(), Schema.maxLength(255)),
priority: Schema.optional(Priority, { default: () => "medium" as const }),
dueDate: Schema.optional(Schema.DateFromString),
tags: Schema.optional(Schema.Array(Schema.String), { default: () => [] as ReadonlyArray<string> })
})
type CreateTodoInput = typeof CreateTodoInput.Type
const TodoOutput = Schema.Struct({
id: Schema.String,
title: Schema.String,
status: TodoStatus,
priority: Priority,
dueDate: Schema.NullOr(Schema.String),
tags: Schema.Array(Schema.String),
createdAt: Schema.String,
updatedAt: Schema.String
})
type TodoOutput = typeof TodoOutput.Type
Итоги главы
| Инструмент | Тип | Когда использовать | Пример |
|---|---|---|---|
Schema.Struct | Структурный | VO, DTO, конфиг, маппинг | Money, CreateTodoInput, TodoRow |
Schema.Class | Номинальный | Entity с поведением | Todo, User, Order |
Schema.TaggedStruct | Структурный + _tag | Члены union без поведения | Простые events |
Schema.TaggedClass | Номинальный + _tag | Domain Events, Commands | TodoCreated, CreateTodo |
Ключевые принципы:
- Struct для данных, Class для поведения: если объект только хранит данные — Struct; если имеет методы и идентичность — Class
- Иммутабельность везде: методы Class возвращают новый экземпляр, а не мутируют текущий
- Smart Constructors: используйте статические фабричные методы для гарантии инвариантов
- TaggedClass для событий:
_tagавтоматически добавляется, что идеально для pattern matching - Extend для наследования: и Struct, и Class поддерживают расширение
В следующей главе мы подробно разберём Branded Types — механизм, превращающий обычные примитивы в типобезопасные доменные идентификаторы.