Типобезопасный домен: Гексагональная архитектура на базе Effect Композиция схем: pipe, extend, pick, omit
Глава

Композиция схем: 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Добавление полей к Structextend(Base, Extra)
pickВыбор подмножества полейTodo.pipe(pick("id", "title"))
omitИсключение полейTodo.pipe(omit("password"))
partialВсе поля → optionalpartial(Todo) — для PATCH
requiredВсе поля → requiredrequired(PartialTodo)
composeЦепочка трансформацийcompose(NumberFromString, Positive)
UnionОбъединение (A | B)Union(Circle, Rectangle)
suspendРекурсивные типыsuspend(() => Schema.Struct({...}))

Итоги главы

Композиция схем — это язык построения доменных моделей:

  1. pipe для уточнения: строим специфичные типы из общих примитивов
  2. extend для наследования: собираем Entity из переиспользуемых миксинов
  3. pick/omit для проекций: создаём DTO и View Models из полной модели
  4. partial/required для вариаций: PATCH-обновления, полные формы
  5. compose для трансформаций: цепочки преобразований на границах
  6. Union для полиморфизма: Domain Events, фильтры, команды

Принцип: определи один раз, используй через композицию. Базовая Schema Entity — это единственный источник истины. Все производные типы (DTO, команды, ответы) создаются через pick/omit/partial, а не копированием.

В следующей главе мы детально разберём Decode/Encode — механизм трансформации данных на границах слоёв гексагональной архитектуры.