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 — это механизм трансформации на границах гексагона:
| Операция | Направление | Где используется |
|---|---|---|
decodeUnknown | unknown → Type | HTTP input, конфиг, env |
decode | Encoded → Type | DB row → Domain |
encode | Type → Encoded | Domain → DB row, JSON response |
transform | Кастомная | SQLite 0/1 ↔ boolean, snake_case ↔ camelCase |
transformOrFail | Кастомная с ошибкой | JSON.parse, URL parsing |
Ключевые принципы:
- Decode на входе, Encode на выходе: каждая граница — точка трансформации
- Ошибки в E-канале:
Schema.decodeUnknownвозвращаетEffect<A, ParseError>, что естественно интегрируется с Effect pipe - Двунаправленность: одна Schema описывает и decode, и encode — нет рассинхронизации
- Transform для маппинга:
Schema.transformсвязывает доменные типы с инфраструктурными - Централизованный маппинг: один файл-маппер на адаптер, а не разбросанные по коду конверсии