Типобезопасный домен: Гексагональная архитектура на базе Effect Decode/Encode: трансформация на границах слоёв
Глава

Decode/Encode: трансформация на границах слоёв

Базовые операции Decode (sync, Either, Effect) и Encode. Schema.transform — кастомные двунаправленные трансформации. Schema.transformOrFail. Трансформации на границах гексагона: HTTP→Domain, Domain→SQLite, Domain→Response. Обработка ParseError. Форматирование ошибок (Tree, Array). Маппинг ParseError→Domain Error. Decode с опциями.

Введение: границы как точки трансформации

В гексагональной архитектуре данные пересекают границы между слоями. На каждой границе данные меняют форму:

HTTP JSON (string)  →  Decode  →  Domain (Date, Brand, VO)  →  Encode  →  SQL Row (string, number)

Decode — трансформация из внешнего формата в доменный (Encoded → Type). Encode — трансформация из доменного формата во внешний (Type → Encoded).

Effect Schema предоставляет мощный механизм двунаправленных трансформаций, который стоит на каждой границе гексагона.


Базовые операции Decode/Encode

Decode: Encoded → Type

import { Schema, Effect, Either } from "effect"

const Todo = Schema.Struct({
  id: Schema.String,
  title: Schema.String.pipe(Schema.nonEmptyString()),
  completed: Schema.Boolean,
  createdAt: Schema.DateFromString // string → Date
})

// ═══════════════════════════════════════
// 1. decodeUnknownSync — синхронный, бросает исключение
// ═══════════════════════════════════════
const todo1 = Schema.decodeUnknownSync(Todo)({
  id: "1",
  title: "Buy milk",
  completed: false,
  createdAt: "2024-01-15T10:30:00Z"
})
// todo1: { id: "1", title: "Buy milk", completed: false, createdAt: Date }
// createdAt — настоящий Date!

// При невалидных данных — throws ParseError
try {
  Schema.decodeUnknownSync(Todo)({ id: "", title: "", completed: "yes" })
} catch (e) {
  // ParseError с детальным описанием всех ошибок
}

// ═══════════════════════════════════════
// 2. decodeUnknownEither — возвращает Either<A, ParseError>
// ═══════════════════════════════════════
const result = Schema.decodeUnknownEither(Todo)({
  id: "1",
  title: "Buy milk",
  completed: false,
  createdAt: "2024-01-15T10:30:00Z"
})
// Either<Todo, ParseError>

Either.match(result, {
  onLeft: (error) => console.error("Validation failed:", error),
  onRight: (todo) => console.log("Valid todo:", todo)
})

// ═══════════════════════════════════════
// 3. decodeUnknown — возвращает Effect<A, ParseError>
// ═══════════════════════════════════════
const program = Schema.decodeUnknown(Todo)({
  id: "1",
  title: "Buy milk",
  completed: false,
  createdAt: "2024-01-15T10:30:00Z"
})
// Effect<Todo, ParseError, never>

// Интегрируется в Effect pipe
const fullProgram = Effect.gen(function* () {
  const todo = yield* Schema.decodeUnknown(Todo)(rawInput)
  // todo уже типизирован как Todo
  // ParseError уже в E-канале
  return todo
})

// ═══════════════════════════════════════
// 4. decode — когда вход уже типизирован как Encoded
// ═══════════════════════════════════════
type TodoEncoded = typeof Todo.Encoded
const encoded: TodoEncoded = {
  id: "1",
  title: "Buy milk",
  completed: false,
  createdAt: "2024-01-15T10:30:00Z" // string, не Date!
}
const decoded = Schema.decodeSync(Todo)(encoded)
// decoded: Todo (с Date вместо string)

Encode: Type → Encoded

import { Schema } from "effect"

const Todo = Schema.Struct({
  id: Schema.String,
  title: Schema.String,
  completed: Schema.Boolean,
  createdAt: Schema.DateFromString
})

const todo: typeof Todo.Type = {
  id: "1",
  title: "Buy milk",
  completed: false,
  createdAt: new Date("2024-01-15T10:30:00Z")
}

// ═══════════════════════════════════════
// Encode — Domain → Serialized
// ═══════════════════════════════════════

// Sync
const encoded = Schema.encodeSync(Todo)(todo)
// { id: "1", title: "Buy milk", completed: false, createdAt: "2024-01-15T10:30:00.000Z" }
// createdAt стал string!

// Either
const resultE = Schema.encodeEither(Todo)(todo)

// Effect
const encodedEffect = Schema.encode(Todo)(todo)
// Effect<TodoEncoded, ParseError, never>

Полный цикл: Decode → Process → Encode

import { Schema, Effect } from "effect"

const Todo = Schema.Struct({
  id: Schema.String,
  title: Schema.String.pipe(Schema.nonEmptyString()),
  completed: Schema.Boolean,
  createdAt: Schema.DateFromString
})

// Полный цикл обработки HTTP-запроса
const handleCreateTodo = (rawBody: unknown) =>
  Effect.gen(function* () {
    // 1. DECODE: JSON → Domain
    const input = yield* Schema.decodeUnknown(Todo)(rawBody)
    // input.createdAt — Date (не string!)

    // 2. PROCESS: бизнес-логика с доменными типами
    const processed = {
      ...input,
      title: input.title.trim(),
      createdAt: new Date() // перезаписываем серверным временем
    }

    // 3. ENCODE: Domain → JSON (для ответа)
    const response = yield* Schema.encode(Todo)(processed)
    // response.createdAt — string (для JSON)

    return response
  })

Schema.transform — кастомные трансформации

Когда встроенных трансформаций (DateFromString, NumberFromString) недостаточно, используем Schema.transform:

import { Schema } from "effect"

// ═══════════════════════════════════════
// Transform: определяет decode И encode
// ═══════════════════════════════════════

// SQLite boolean: 0/1 ↔ true/false
const SqliteBoolean = Schema.transform(
  Schema.Literal(0, 1),    // From (Encoded side)
  Schema.Boolean,           // To (Type side)
  {
    strict: true,
    decode: (n) => n === 1,             // 0/1 → boolean
    encode: (b) => (b ? 1 : 0) as 0 | 1  // boolean → 0/1
  }
)
// Type = boolean, Encoded = 0 | 1

Schema.decodeUnknownSync(SqliteBoolean)(1)     // true
Schema.decodeUnknownSync(SqliteBoolean)(0)     // false
Schema.encodeSync(SqliteBoolean)(true)          // 1
Schema.encodeSync(SqliteBoolean)(false)         // 0

// ═══════════════════════════════════════
// Comma-separated tags: "work,urgent" ↔ ["work", "urgent"]
// ═══════════════════════════════════════
const CommaTags = Schema.transform(
  Schema.String,                          // From: string
  Schema.Array(Schema.String),            // To: ReadonlyArray<string>
  {
    strict: true,
    decode: (s) => s === "" ? [] : s.split(",").map(t => t.trim()),
    encode: (arr) => arr.join(",")
  }
)

Schema.decodeUnknownSync(CommaTags)("work,urgent,later")
// ["work", "urgent", "later"]

Schema.encodeSync(CommaTags)(["work", "urgent"])
// "work,urgent"

// ═══════════════════════════════════════
// Unix timestamp ↔ Date
// ═══════════════════════════════════════
const UnixTimestamp = Schema.transform(
  Schema.Number,     // From: number (seconds since epoch)
  Schema.DateFromSelf, // To: Date
  {
    strict: true,
    decode: (n) => new Date(n * 1000),
    encode: (d) => Math.floor(d.getTime() / 1000)
  }
)

Schema.transformOrFail — трансформации с возможной ошибкой

Когда decode может завершиться ошибкой:

import { Schema, ParseResult, Effect } from "effect"

// JSON string ↔ parsed object
const JsonString = Schema.transformOrFail(
  Schema.String,      // From: string
  Schema.Unknown,     // To: unknown (parsed JSON)
  {
    strict: true,
    decode: (s, _, ast) =>
      Effect.try({
        try: () => JSON.parse(s),
        catch: () => new ParseResult.Type(ast, s, "Invalid JSON string")
      }),
    encode: (obj) =>
      Effect.succeed(JSON.stringify(obj))
  }
)

// URL string ↔ URL object
const UrlFromString = Schema.transformOrFail(
  Schema.String,
  Schema.instanceOf(URL),
  {
    strict: true,
    decode: (s, _, ast) =>
      Effect.try({
        try: () => new URL(s),
        catch: () => new ParseResult.Type(ast, s, "Invalid URL")
      }),
    encode: (url) =>
      Effect.succeed(url.toString())
  }
)

Трансформация на границах гексагона

Граница 1: HTTP → Domain (Driving Adapter)

import { Schema, Effect } from "effect"

// ═══════════════════════════════════════
// Доменные типы
// ═══════════════════════════════════════
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

const Priority = Schema.Literal("low", "medium", "high")
type Priority = typeof Priority.Type

// ═══════════════════════════════════════
// HTTP Input Schema (то, что приходит из JSON)
// ═══════════════════════════════════════
const CreateTodoRequest = Schema.Struct({
  title: Schema.String,       // Обычная строка из JSON
  priority: Schema.optional(Schema.String, { default: () => "medium" }),
  dueDate: Schema.optional(Schema.String) // ISO string
})

// ═══════════════════════════════════════
// Domain Input Schema (то, что нужно UseCase)
// ═══════════════════════════════════════
const CreateTodoInput = Schema.Struct({
  title: TodoTitle,           // Branded + validated
  priority: Priority,         // Literal union
  dueDate: Schema.optional(Schema.DateFromString)
})

// ═══════════════════════════════════════
// Трансформация: HTTP → Domain
// ═══════════════════════════════════════
const CreateTodoFromRequest = Schema.transform(
  CreateTodoRequest,
  CreateTodoInput,
  {
    strict: true,
    decode: (req) => ({
      title: req.title as TodoTitle,      // Brand applied through Schema
      priority: req.priority as Priority,
      dueDate: req.dueDate
    }),
    encode: (input) => ({
      title: input.title as string,
      priority: input.priority as string,
      dueDate: input.dueDate
    })
  }
)

// Или проще — decode напрямую через доменную Schema:
const handleRequest = (rawBody: unknown) =>
  Effect.gen(function* () {
    // Schema.decodeUnknown проверит все правила и применит Brand
    const input = yield* Schema.decodeUnknown(CreateTodoInput)(rawBody)
    // input.title: TodoTitle (branded, trimmed, validated)
    // input.priority: "low" | "medium" | "high" (validated)
    return input
  })

Граница 2: Domain → SQLite (Driven Adapter)

import { Schema, Effect } from "effect"

// ═══════════════════════════════════════
// Domain Entity
// ═══════════════════════════════════════
class Todo extends Schema.Class<Todo>("Todo")({
  id: Schema.String.pipe(Schema.brand("TodoId")),
  title: Schema.Trim.pipe(Schema.nonEmptyString(), Schema.brand("TodoTitle")),
  completed: Schema.Boolean,
  priority: Schema.Literal("low", "medium", "high"),
  createdAt: Schema.DateFromString,
  updatedAt: Schema.DateFromString,
}) {}

// ═══════════════════════════════════════
// SQLite Row Schema
// ═══════════════════════════════════════
const TodoRow = Schema.Struct({
  id: Schema.String,
  title: Schema.String,
  completed: Schema.Number, // SQLite: 0 | 1
  priority: Schema.String,
  created_at: Schema.String, // snake_case в БД
  updated_at: Schema.String
})
type TodoRow = typeof TodoRow.Type

// ═══════════════════════════════════════
// Transform: Domain ↔ SQLite Row
// ═══════════════════════════════════════
const TodoToRow = Schema.transform(
  Todo,
  TodoRow,
  {
    strict: true,
    decode: (row) => new Todo({
      id: row.id as any,
      title: row.title as any,
      completed: row.completed === 1,
      priority: row.priority as any,
      createdAt: new Date(row.created_at),
      updatedAt: new Date(row.updated_at)
    }),
    encode: (todo) => ({
      id: todo.id as string,
      title: todo.title as string,
      completed: todo.completed ? 1 : 0,
      priority: todo.priority,
      created_at: todo.createdAt.toISOString(),
      updated_at: todo.updatedAt.toISOString()
    })
  }
)

// Использование в адаптере:
const todoFromRow = (row: TodoRow) =>
  Schema.decodeSync(TodoToRow)(row)

const todoToRow = (todo: Todo) =>
  Schema.encodeSync(TodoToRow)(todo)

Граница 3: Domain → HTTP Response (Driving Adapter, обратный путь)

import { Schema } from "effect"

// ═══════════════════════════════════════
// HTTP Response Schema
// ═══════════════════════════════════════
const TodoResponse = Schema.Struct({
  id: Schema.String,
  title: Schema.String,
  completed: Schema.Boolean,
  priority: Schema.String,
  createdAt: Schema.String,      // ISO string для JSON
  updatedAt: Schema.String,
  isOverdue: Schema.Boolean      // computed field
})

// Маппинг Domain → Response
const todoToResponse = (todo: Todo, now: Date): typeof TodoResponse.Type => ({
  id: todo.id as string,
  title: todo.title as string,
  completed: todo.completed,
  priority: todo.priority,
  createdAt: todo.createdAt.toISOString(),
  updatedAt: todo.updatedAt.toISOString(),
  isOverdue: false // todo.isOverdue(now)
})

Обработка ошибок декодирования

ParseError — структурированная ошибка

import { Schema, Effect } from "effect"

const Todo = Schema.Struct({
  id: Schema.String.pipe(Schema.nonEmptyString()),
  title: Schema.String.pipe(Schema.nonEmptyString(), Schema.maxLength(255)),
  completed: Schema.Boolean,
  priority: Schema.Literal("low", "medium", "high")
})

// ParseError содержит ВСЕ ошибки, не только первую
const result = Schema.decodeUnknownEither(Todo)({
  id: "",
  title: "",
  completed: "yes",
  priority: "urgent"
})

// result: Left(ParseError) с деревом ошибок:
// - id: Expected non-empty string, got ""
// - title: Expected non-empty string, got ""
// - completed: Expected boolean, got "yes"
// - priority: Expected "low" | "medium" | "high", got "urgent"

Форматирование ошибок

import { Schema, TreeFormatter, ArrayFormatter } from "effect"

const Todo = Schema.Struct({
  title: Schema.String.pipe(Schema.nonEmptyString()),
  priority: Schema.Literal("low", "medium", "high")
})

try {
  Schema.decodeUnknownSync(Todo)({ title: "", priority: "urgent" })
} catch (error) {
  if (Schema.isParseError(error)) {
    // TreeFormatter — древовидный формат (для логов)
    console.log(TreeFormatter.formatErrorSync(error))
    // { title: "Expected non-empty string, actual ''"
    //   priority: "Expected 'low' | 'medium' | 'high', actual 'urgent'" }

    // ArrayFormatter — плоский список (для API-ответов)
    const issues = ArrayFormatter.formatErrorSync(error)
    // [
    //   { path: ["title"], message: "Expected non-empty string..." },
    //   { path: ["priority"], message: "Expected 'low' | 'medium' | 'high'..." }
    // ]
  }
}

Маппинг ParseError → Domain Error

import { Schema, Effect, Data } from "effect"

// Доменная ошибка валидации
class ValidationError extends Data.TaggedError("ValidationError")<{
  readonly field: string
  readonly message: string
  readonly value: unknown
}> {}

class MultiValidationError extends Data.TaggedError("MultiValidationError")<{
  readonly errors: ReadonlyArray<ValidationError>
}> {}

// Маппинг ParseError → MultiValidationError
const decodeWithDomainError = <A, I>(schema: Schema.Schema<A, I>) =>
  (input: unknown): Effect.Effect<A, MultiValidationError> =>
    Schema.decodeUnknown(schema)(input).pipe(
      Effect.mapError((parseError) => {
        const issues = Schema.ArrayFormatter.formatErrorSync(parseError)
        return new MultiValidationError({
          errors: issues.map(issue => new ValidationError({
            field: issue.path.join("."),
            message: issue.message,
            value: input
          }))
        })
      })
    )

// Использование
const decodeTodo = decodeWithDomainError(Todo)
// Effect<Todo, MultiValidationError, never>

Decode с опциями

Schema.decode принимает опции, влияющие на поведение:

import { Schema } from "effect"

const User = Schema.Struct({
  name: Schema.String,
  age: Schema.Number
})

// ═══════════════════════════════════════
// onExcessProperty: что делать с лишними полями
// ═══════════════════════════════════════

// "ignore" (default) — лишние поля отбрасываются
Schema.decodeUnknownSync(User)({ name: "A", age: 30, extra: "x" })
// { name: "A", age: 30 }

// "error" — лишние поля вызывают ошибку
Schema.decodeUnknownSync(User)(
  { name: "A", age: 30, extra: "x" },
  { onExcessProperty: "error" }
)
// throws ParseError: unexpected key "extra"

// "preserve" — лишние поля сохраняются
Schema.decodeUnknownSync(User)(
  { name: "A", age: 30, extra: "x" },
  { onExcessProperty: "preserve" }
)
// { name: "A", age: 30, extra: "x" }

// ═══════════════════════════════════════
// errors: сколько ошибок собирать
// ═══════════════════════════════════════

// "first" (default) — останавливается на первой ошибке
// "all" — собирает ВСЕ ошибки
Schema.decodeUnknownSync(User)(
  { name: 123, age: "thirty" },
  { errors: "all" }
)
// ParseError со списком ВСЕХ ошибок (name и age)

Паттерн: Encode/Decode между слоями адаптера

import { Schema, Effect, Context, Layer } from "effect"

// ═══════════════════════════════════════
// Schema маппинга (определяется один раз)
// ═══════════════════════════════════════
const todoRowSchema = Schema.transform(
  Schema.Struct({
    id: Schema.String,
    title: Schema.String,
    completed: Schema.Number,
    priority: Schema.String,
    created_at: Schema.String,
    updated_at: Schema.String
  }),
  Schema.Struct({
    id: Schema.String.pipe(Schema.brand("TodoId")),
    title: Schema.Trim.pipe(Schema.nonEmptyString(), Schema.brand("TodoTitle")),
    completed: Schema.Boolean,
    priority: Schema.Literal("low", "medium", "high"),
    createdAt: Schema.DateFromString,
    updatedAt: Schema.DateFromString
  }),
  {
    strict: true,
    decode: (row) => ({
      id: row.id as any,
      title: row.title as any,
      completed: row.completed === 1,
      priority: row.priority as any,
      createdAt: row.created_at,
      updatedAt: row.updated_at
    }),
    encode: (todo) => ({
      id: todo.id as string,
      title: todo.title as string,
      completed: todo.completed ? 1 : 0,
      priority: todo.priority,
      created_at: typeof todo.createdAt === 'string' ? todo.createdAt : todo.createdAt,
      updated_at: typeof todo.updatedAt === 'string' ? todo.updatedAt : todo.updatedAt
    })
  }
)

// Хелперы для адаптера
const decodeTodoRow = Schema.decodeSync(todoRowSchema)
const encodeTodoRow = Schema.encodeSync(todoRowSchema)

Итоги главы

Decode/Encode — это механизм трансформации на границах гексагона:

ОперацияНаправлениеГде используется
decodeUnknownunknown → TypeHTTP input, конфиг, env
decodeEncoded → TypeDB row → Domain
encodeType → EncodedDomain → DB row, JSON response
transformКастомнаяSQLite 0/1 ↔ boolean, snake_case ↔ camelCase
transformOrFailКастомная с ошибкойJSON.parse, URL parsing

Ключевые принципы:

  1. Decode на входе, Encode на выходе: каждая граница — точка трансформации
  2. Ошибки в E-канале: Schema.decodeUnknown возвращает Effect<A, ParseError>, что естественно интегрируется с Effect pipe
  3. Двунаправленность: одна Schema описывает и decode, и encode — нет рассинхронизации
  4. Transform для маппинга: Schema.transform связывает доменные типы с инфраструктурными
  5. Централизованный маппинг: один файл-маппер на адаптер, а не разбросанные по коду конверсии