Композиция схем: pipe, extend, pick, omit
pipe — последовательное уточнение. extend — расширение структуры, миксины. pick — выбор подмножества полей. omit — исключение полей. partial/required — изменение опциональности. compose — цепочка трансформаций. Union — объединение схем. Паттерн CRUD-схемы из одного определения. Система фильтрации.
Введение: схемы как строительные блоки
Сила Effect Schema — в композиции. Вместо написания огромных монолитных определений мы собираем сложные схемы из простых блоков, как конструктор LEGO. Это полностью соответствует принципам функционального программирования: маленькие, переиспользуемые, композируемые единицы.
В этой главе мы разберём все способы композиции схем:
- pipe — последовательное уточнение (рефайнменты, трансформации)
- extend — расширение структуры новыми полями
- pick — выбор подмножества полей
- omit — исключение полей
- partial / required — изменение опциональности
- compose — цепочка трансформаций
- Union / intersect — объединение и пересечение
pipe — последовательная композиция
pipe — фундаментальная операция в Effect. Для Schema она означает последовательное уточнение типа:
import { Schema } from "effect"
// Каждый шаг pipe сужает допустимые значения
const SafeUsername = Schema.String.pipe(
Schema.trimmed(), // 1. Без ведущих/замыкающих пробелов
Schema.lowercased(), // 2. Только lowercase
Schema.minLength(3), // 3. Минимум 3 символа
Schema.maxLength(30), // 4. Максимум 30 символов
Schema.pattern(/^[a-z0-9_]+$/), // 5. Только буквы, цифры, подчёркивание
Schema.brand("Username"), // 6. Branded type
Schema.annotations({ // 7. Метаданные
title: "Username",
description: "Имя пользователя: 3-30 символов, lowercase, буквы/цифры/underscore"
})
)
type SafeUsername = typeof SafeUsername.Type
// string & Brand<"Username">
Порядок рефайнментов в pipe имеет значение:
// ✅ Правильный порядок: от общих проверок к специфичным
const GoodOrder = Schema.String.pipe(
Schema.nonEmptyString(), // сначала — самые общие
Schema.minLength(3), // потом — конкретные длины
Schema.maxLength(100),
Schema.pattern(/^[a-z]+$/), // потом — формат
Schema.brand("MyType") // brand — всегда последний перед annotations
)
// ⚠️ Нежелательный порядок: brand перед рефайнментами
// Schema.brand("MyType") → Schema.minLength(3)
// Технически работает, но менее читаемо
Вынесение цепочек в функции
import { Schema } from "effect"
// Переиспользуемая цепочка рефайнментов
const boundedTrimmedString = (min: number, max: number) =>
Schema.Trim.pipe(
Schema.nonEmptyString(),
Schema.minLength(min),
Schema.maxLength(max)
)
// Применение
const TodoTitle = boundedTrimmedString(1, 255).pipe(
Schema.brand("TodoTitle")
)
const TodoDescription = boundedTrimmedString(1, 5000).pipe(
Schema.brand("TodoDescription")
)
const Username = boundedTrimmedString(3, 30).pipe(
Schema.pattern(/^[a-z0-9_]+$/),
Schema.brand("Username")
)
extend — расширение структуры
Schema.extend добавляет новые поля к существующему Struct:
import { Schema } from "effect"
// Базовая структура
const Timestamped = Schema.Struct({
createdAt: Schema.DateFromString,
updatedAt: Schema.DateFromString
})
// Расширение
const Todo = Schema.extend(
Timestamped,
Schema.Struct({
id: Schema.String,
title: Schema.String.pipe(Schema.nonEmptyString()),
completed: Schema.Boolean
})
)
type Todo = typeof Todo.Type
// {
// readonly createdAt: Date
// readonly updatedAt: Date
// readonly id: string
// readonly title: string
// readonly completed: boolean
// }
Паттерн: базовые миксины
import { Schema } from "effect"
// ═══════════════════════════════════════
// Базовые миксины (переиспользуемые части)
// ═══════════════════════════════════════
/** Миксин: поля идентификации */
const Identifiable = Schema.Struct({
id: Schema.UUID
})
/** Миксин: поля аудита */
const Auditable = Schema.Struct({
createdAt: Schema.DateFromString,
updatedAt: Schema.DateFromString,
createdBy: Schema.optional(Schema.String)
})
/** Миксин: мягкое удаление */
const SoftDeletable = Schema.Struct({
deletedAt: Schema.optional(Schema.DateFromString, { as: "Option" }),
isDeleted: Schema.optional(Schema.Boolean, { default: () => false })
})
/** Миксин: версионирование (optimistic locking) */
const Versioned = Schema.Struct({
version: Schema.Number.pipe(Schema.int(), Schema.nonNegative())
})
// ═══════════════════════════════════════
// Сборка Entity из миксинов
// ═══════════════════════════════════════
const TodoFields = Schema.Struct({
title: Schema.String.pipe(Schema.nonEmptyString()),
completed: Schema.Boolean,
priority: Schema.Literal("low", "medium", "high")
})
// Композиция через последовательные extend
const Todo = Schema.extend(
Schema.extend(
Schema.extend(Identifiable, Auditable),
Versioned
),
TodoFields
)
type Todo = typeof Todo.Type
// {
// readonly id: string
// readonly createdAt: Date
// readonly updatedAt: Date
// readonly createdBy?: string
// readonly version: number
// 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.UUID,
createdAt: Schema.DateFromString,
updatedAt: Schema.DateFromString
}) {
get ageMs(): number {
return Date.now() - this.createdAt.getTime()
}
}
class Todo extends BaseEntity.extend<Todo>("Todo")({
title: Schema.String.pipe(Schema.nonEmptyString()),
completed: Schema.Boolean,
priority: Schema.Literal("low", "medium", "high")
}) {
complete(): Todo {
return new Todo({ ...this, completed: true, updatedAt: new Date() })
}
}
const todo = new Todo({
id: crypto.randomUUID(),
title: "Buy milk",
completed: false,
priority: "medium",
createdAt: new Date(),
updatedAt: new Date()
})
console.log(todo.ageMs) // Метод базового класса
console.log(todo.complete()) // Метод наследника
console.log(todo instanceof BaseEntity) // true
pick — выбор полей
Schema.pick создаёт подмножество полей из существующей Schema:
import { Schema } from "effect"
const User = Schema.Struct({
id: Schema.String,
name: Schema.String,
email: Schema.String,
passwordHash: Schema.String,
role: Schema.Literal("admin", "user"),
createdAt: Schema.DateFromString
})
// Публичный профиль — только безопасные поля
const UserProfile = User.pipe(Schema.pick("id", "name", "role"))
type UserProfile = typeof UserProfile.Type
// { readonly id: string; readonly name: string; readonly role: "admin" | "user" }
// Для списка пользователей — минимум данных
const UserListItem = User.pipe(Schema.pick("id", "name"))
type UserListItem = typeof UserListItem.Type
// { readonly id: string; readonly name: string }
Практические применения pick
import { Schema } from "effect"
const Todo = Schema.Struct({
id: Schema.String,
title: Schema.String.pipe(Schema.nonEmptyString()),
description: Schema.optional(Schema.String),
completed: Schema.Boolean,
priority: Schema.Literal("low", "medium", "high"),
createdAt: Schema.DateFromString,
updatedAt: Schema.DateFromString,
assigneeId: Schema.optional(Schema.String)
})
// ═══════════════════════════════════════
// DTOs через pick
// ═══════════════════════════════════════
/** Ответ для списка задач (без description и metadata) */
const TodoListItemResponse = Todo.pipe(
Schema.pick("id", "title", "completed", "priority")
)
/** Минимальная информация для уведомления */
const TodoNotification = Todo.pipe(
Schema.pick("id", "title", "assigneeId")
)
/** Данные для аудит-лога */
const TodoAuditRecord = Todo.pipe(
Schema.pick("id", "title", "completed", "updatedAt")
)
omit — исключение полей
Schema.omit — обратная операция к pick. Создаёт структуру без указанных полей:
import { Schema } from "effect"
const User = Schema.Struct({
id: Schema.String,
name: Schema.String,
email: Schema.String,
passwordHash: Schema.String,
role: Schema.Literal("admin", "user"),
createdAt: Schema.DateFromString,
updatedAt: Schema.DateFromString
})
// Убираем чувствительные данные
const SafeUser = User.pipe(Schema.omit("passwordHash"))
type SafeUser = typeof SafeUser.Type
// { id, name, email, role, createdAt, updatedAt — всё кроме passwordHash }
// Input для создания — без id и timestamp (они генерируются)
const CreateUserInput = User.pipe(Schema.omit("id", "createdAt", "updatedAt"))
type CreateUserInput = typeof CreateUserInput.Type
// { name, email, passwordHash, role }
// Input для обновления — без id и timestamp
const UpdateUserInput = User.pipe(Schema.omit("id", "createdAt", "updatedAt"))
pick vs omit: когда что
pick — когда нужно МАЛО полей из БОЛЬШОЙ структуры
omit — когда нужно ПОЧТИ ВСЕ поля, за исключением нескольких
partial и required — изменение опциональности
Schema.partial — все поля становятся optional
import { Schema } from "effect"
const Todo = Schema.Struct({
title: Schema.String,
completed: Schema.Boolean,
priority: Schema.Literal("low", "medium", "high")
})
// Все поля optional — идеально для PATCH-обновлений
const TodoPatch = Schema.partial(Todo)
type TodoPatch = typeof TodoPatch.Type
// {
// readonly title?: string | undefined
// readonly completed?: boolean | undefined
// readonly priority?: "low" | "medium" | "high" | undefined
// }
// Можно обновить только часть полей
Schema.decodeUnknownSync(TodoPatch)({ title: "New title" })
// { title: "New title" }
Schema.decodeUnknownSync(TodoPatch)({})
// {} — пустой объект тоже валиден
Partial с exact
import { Schema } from "effect"
const Todo = Schema.Struct({
title: Schema.String,
completed: Schema.Boolean
})
// С exact: true — поля могут быть undefined, но не отсутствовать
const TodoPatchExact = Schema.partial(Todo, { exact: true })
type TodoPatchExact = typeof TodoPatchExact.Type
// {
// readonly title?: string | undefined (ключ может отсутствовать)
// readonly completed?: boolean | undefined
// }
Schema.required — все поля обязательны
import { Schema } from "effect"
const OptionalProfile = Schema.Struct({
name: Schema.optional(Schema.String),
bio: Schema.optional(Schema.String),
avatar: Schema.optional(Schema.String)
})
// Полный профиль — все поля обязательны
const CompleteProfile = Schema.required(OptionalProfile)
type CompleteProfile = typeof CompleteProfile.Type
// { readonly name: string; readonly bio: string; readonly avatar: string }
Паттерн: CRUD-схемы из одного определения
import { Schema } from "effect"
// ═══════════════════════════════════════
// Базовое определение Entity
// ═══════════════════════════════════════
const TodoBase = Schema.Struct({
id: Schema.UUID,
title: Schema.String.pipe(Schema.nonEmptyString(), Schema.maxLength(255)),
description: Schema.optional(Schema.String.pipe(Schema.maxLength(5000))),
completed: Schema.Boolean,
priority: Schema.Literal("low", "medium", "high"),
createdAt: Schema.DateFromString,
updatedAt: Schema.DateFromString
})
// ═══════════════════════════════════════
// Производные схемы для CRUD
// ═══════════════════════════════════════
/** CREATE — без id и timestamps (генерируются сервером) */
const CreateTodoInput = TodoBase.pipe(
Schema.omit("id", "createdAt", "updatedAt", "completed"),
// completed по умолчанию false — не требуем от клиента
)
type CreateTodoInput = typeof CreateTodoInput.Type
// { title: string; description?: string; priority: "low" | "medium" | "high" }
/** READ — полная модель */
const TodoOutput = TodoBase
type TodoOutput = typeof TodoOutput.Type
/** UPDATE (PATCH) — все поля опциональны, кроме id */
const UpdateTodoInput = TodoBase.pipe(
Schema.omit("id", "createdAt", "updatedAt"),
Schema.partial
)
type UpdateTodoInput = typeof UpdateTodoInput.Type
// { title?: string; description?: string; completed?: boolean; priority?: ... }
/** LIST — краткая модель для списка */
const TodoListItem = TodoBase.pipe(
Schema.pick("id", "title", "completed", "priority", "updatedAt")
)
type TodoListItem = typeof TodoListItem.Type
compose — цепочка трансформаций
Schema.compose соединяет две схемы: выход первой становится входом второй:
import { Schema } from "effect"
// Schema A: string → number
const ParseNumber = Schema.NumberFromString
// Encoded: string, Type: number
// Schema B: number → добавляем рефайнмент
const PositiveNumber = Schema.Number.pipe(Schema.positive())
// Compose: string → number → positive number
const PositiveFromString = Schema.compose(ParseNumber, PositiveNumber)
// Encoded: string, Type: number (positive)
Schema.decodeUnknownSync(PositiveFromString)("42") // 42
Schema.decodeUnknownSync(PositiveFromString)("-1") // ParseError: not positive
Schema.decodeUnknownSync(PositiveFromString)("abc") // ParseError: not a number
Цепочка трансформаций
import { Schema } from "effect"
// Пример: CSV строка → массив чисел → отфильтрованный массив
// Шаг 1: строка → массив строк
const CsvToArray = Schema.split(",")
// "1,2,3" → ["1", "2", "3"]
// Шаг 2: массив строк → массив чисел
const StringsToNumbers = Schema.Array(Schema.NumberFromString)
// ["1", "2", "3"] → [1, 2, 3]
// Compose: строка → массив чисел
const CsvToNumbers = Schema.compose(CsvToArray, StringsToNumbers)
// "1,2,3" → [1, 2, 3]
Schema.decodeUnknownSync(CsvToNumbers)("10,20,30")
// [10, 20, 30]
Union — объединение схем
Schema.Union создаёт тип-объединение. Schema автоматически определяет дискриминант:
import { Schema } from "effect"
// Discriminated union — самый мощный паттерн
const TodoFilter = Schema.Union(
Schema.Struct({
_tag: Schema.Literal("ByStatus"),
status: Schema.Literal("active", "completed", "archived")
}),
Schema.Struct({
_tag: Schema.Literal("ByPriority"),
priority: Schema.Literal("low", "medium", "high")
}),
Schema.Struct({
_tag: Schema.Literal("ByDateRange"),
from: Schema.DateFromString,
to: Schema.DateFromString
}),
Schema.Struct({
_tag: Schema.Literal("BySearch"),
query: Schema.String.pipe(Schema.nonEmptyString())
})
)
type TodoFilter = typeof TodoFilter.Type
// Schema автоматически использует _tag как дискриминант
// Decode — Schema определяет тип по _tag
const filter = Schema.decodeUnknownSync(TodoFilter)({
_tag: "ByPriority",
priority: "high"
})
// filter._tag === "ByPriority", filter.priority === "high"
// Pattern matching
const describeFilter = (f: TodoFilter): string => {
switch (f._tag) {
case "ByStatus": return `Status: ${f.status}`
case "ByPriority": return `Priority: ${f.priority}`
case "ByDateRange": return `From ${f.from} to ${f.to}`
case "BySearch": return `Search: ${f.query}`
}
}
Union для Domain Events
import { Schema } from "effect"
// Каждое событие — TaggedClass
class TodoCreated extends Schema.TaggedClass<TodoCreated>()("TodoCreated", {
todoId: Schema.String,
title: Schema.String,
occurredAt: Schema.DateFromString
}) {}
class TodoCompleted extends Schema.TaggedClass<TodoCompleted>()("TodoCompleted", {
todoId: Schema.String,
occurredAt: Schema.DateFromString
}) {}
class TodoPriorityChanged extends Schema.TaggedClass<TodoPriorityChanged>()("TodoPriorityChanged", {
todoId: Schema.String,
from: Schema.Literal("low", "medium", "high"),
to: Schema.Literal("low", "medium", "high"),
occurredAt: Schema.DateFromString
}) {}
// Union всех событий
const TodoEvent = Schema.Union(
TodoCreated,
TodoCompleted,
TodoPriorityChanged
)
type TodoEvent = typeof TodoEvent.Type
// Сериализация/десериализация событий — автоматическая!
const eventJson = Schema.encodeSync(TodoEvent)(new TodoCreated({
todoId: "1",
title: "Buy milk",
occurredAt: new Date()
}))
// { _tag: "TodoCreated", todoId: "1", title: "Buy milk", occurredAt: "2024-01-01T..." }
const event = Schema.decodeUnknownSync(TodoEvent)(eventJson)
// event: TodoCreated (по _tag определён правильный класс)
Практический пример: система фильтрации Todo
Соберём все техники композиции в реальном примере:
import { Schema, Option } from "effect"
// ═══════════════════════════════════════
// 1. Базовые типы
// ═══════════════════════════════════════
const TodoStatus = Schema.Literal("draft", "active", "completed", "archived")
const Priority = Schema.Literal("low", "medium", "high", "critical")
const SortField = Schema.Literal("createdAt", "updatedAt", "priority", "title")
const SortDirection = Schema.Literal("asc", "desc")
// ═══════════════════════════════════════
// 2. Композиция: SortOrder из двух литералов
// ═══════════════════════════════════════
const SortOrder = Schema.Struct({
field: SortField,
direction: Schema.optional(SortDirection, { default: () => "desc" as const })
})
// ═══════════════════════════════════════
// 3. Pagination — переиспользуемый миксин
// ═══════════════════════════════════════
const Pagination = Schema.Struct({
page: Schema.optional(Schema.Number.pipe(Schema.int(), Schema.positive()), {
default: () => 1
}),
pageSize: Schema.optional(Schema.Number.pipe(Schema.int(), Schema.between(1, 100)), {
default: () => 20
})
})
// ═══════════════════════════════════════
// 4. Фильтры — extend Pagination
// ═══════════════════════════════════════
const TodoFilters = Schema.extend(
Pagination,
Schema.Struct({
status: Schema.optional(TodoStatus),
priority: Schema.optional(Priority),
search: Schema.optional(Schema.String.pipe(Schema.nonEmptyString())),
assigneeId: Schema.optional(Schema.String),
dueBefore: Schema.optional(Schema.DateFromString),
dueAfter: Schema.optional(Schema.DateFromString),
tags: Schema.optional(Schema.Array(Schema.String)),
sort: Schema.optional(SortOrder, {
default: () => ({ field: "createdAt" as const, direction: "desc" as const })
})
})
)
type TodoFilters = typeof TodoFilters.Type
// Полный тип фильтрации с пагинацией и сортировкой
// ═══════════════════════════════════════
// 5. Ответ — extend Pagination + результаты
// ═══════════════════════════════════════
// Элемент списка — pick из полной модели
const TodoListItem = Schema.Struct({
id: Schema.String,
title: Schema.String,
status: TodoStatus,
priority: Priority,
dueDate: Schema.NullOr(Schema.String),
updatedAt: Schema.String
})
const TodoListResponse = Schema.extend(
Pagination,
Schema.Struct({
items: Schema.Array(TodoListItem),
totalCount: Schema.Number.pipe(Schema.int(), Schema.nonNegative()),
totalPages: Schema.Number.pipe(Schema.int(), Schema.nonNegative())
})
)
type TodoListResponse = typeof TodoListResponse.Type
Таблица всех операций композиции
| Операция | Описание | Пример |
|---|---|---|
pipe | Последовательное уточнение | String.pipe(minLength(1), brand("X")) |
extend | Добавление полей к Struct | extend(Base, Extra) |
pick | Выбор подмножества полей | Todo.pipe(pick("id", "title")) |
omit | Исключение полей | Todo.pipe(omit("password")) |
partial | Все поля → optional | partial(Todo) — для PATCH |
required | Все поля → required | required(PartialTodo) |
compose | Цепочка трансформаций | compose(NumberFromString, Positive) |
Union | Объединение (A | B) | Union(Circle, Rectangle) |
suspend | Рекурсивные типы | suspend(() => Schema.Struct({...})) |
Итоги главы
Композиция схем — это язык построения доменных моделей:
- pipe для уточнения: строим специфичные типы из общих примитивов
- extend для наследования: собираем Entity из переиспользуемых миксинов
- pick/omit для проекций: создаём DTO и View Models из полной модели
- partial/required для вариаций: PATCH-обновления, полные формы
- compose для трансформаций: цепочки преобразований на границах
- Union для полиморфизма: Domain Events, фильтры, команды
Принцип: определи один раз, используй через композицию. Базовая Schema Entity — это единственный источник истины. Все производные типы (DTO, команды, ответы) создаются через pick/omit/partial, а не копированием.
В следующей главе мы детально разберём Decode/Encode — механизм трансформации данных на границах слоёв гексагональной архитектуры.