Типобезопасный домен: Гексагональная архитектура на базе Effect Базовые схемы: String, Number, Boolean и их рефайнменты
Глава

Базовые схемы: String, Number, Boolean и их рефайнменты

Строковые схемы и рефайнменты (длина, паттерн, trim, lowercase). Числовые схемы (int, positive, between, finite). Boolean и трансформации. Литеральные типы и перечисления. Nullable/Optional. Date, BigDecimal. Кастомные рефайнменты через filter. Коллекции: Array, Tuple, Record. Union. Аннотации.

Введение: атомы доменного моделирования

Любая сложная доменная модель строится из примитивных строительных блоков. В Effect Schema эти блоки — базовые схемы для примитивных типов TypeScript. Но в отличие от голых string, number и boolean, Schema позволяет уточнять (refine) эти типы, добавляя бизнес-ограничения прямо в определение.

Ключевой принцип: примитивные типы TypeScript слишком широки для домена. string может содержать пустую строку, строку длиной в миллион символов, невалидный email — что угодно. Наша задача — сузить типы до того, что действительно допустимо в нашем домене.


Schema.String — строковые типы

Базовая схема

import { Schema } from "effect"

// Базовая строковая схема — принимает любую строку
const Name = Schema.String
type Name = typeof Name.Type // string

// Decode — проверяет, что значение является строкой
Schema.decodeUnknownSync(Name)("hello")  // "hello"
Schema.decodeUnknownSync(Name)(42)       // throws ParseError
Schema.decodeUnknownSync(Name)(null)     // throws ParseError

Рефайнменты для строк

Рефайнмент (refinement) — это сужение типа через добавление ограничения. Schema предоставляет богатый набор встроенных рефайнментов:

Ограничения длины

import { Schema } from "effect"

// Непустая строка — фундаментальный рефайнмент для домена
const NonEmpty = Schema.String.pipe(
  Schema.nonEmptyString()
)
// Принимает: "hello", "a", " " (пробел — валидный символ)
// Отвергает: ""

// Минимальная длина
const AtLeast3 = Schema.String.pipe(
  Schema.minLength(3)
)
// Принимает: "abc", "hello world"
// Отвергает: "", "ab"

// Максимальная длина
const AtMost100 = Schema.String.pipe(
  Schema.maxLength(100)
)

// Точная длина
const Code6 = Schema.String.pipe(
  Schema.length(6)
)
// Только строки длиной ровно 6 символов

// Комбинация — диапазон длины
const Username = Schema.String.pipe(
  Schema.minLength(3),
  Schema.maxLength(30),
  Schema.annotations({
    title: "Username",
    description: "Имя пользователя от 3 до 30 символов"
  })
)

Паттерны (регулярные выражения)

// Паттерн — проверка через регулярное выражение
const HexColor = Schema.String.pipe(
  Schema.pattern(/^#[0-9a-fA-F]{6}$/),
  Schema.annotations({ title: "HexColor", description: "Цвет в формате #RRGGBB" })
)

// Slug для URL
const Slug = Schema.String.pipe(
  Schema.pattern(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
  Schema.minLength(1),
  Schema.maxLength(100)
)

// UUID v4
const UUIDv4 = Schema.String.pipe(
  Schema.pattern(
    /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
  )
)

Встроенные строковые фильтры

Effect Schema предоставляет готовые рефайнменты для распространённых случаев:

// Trimmed — строка без ведущих/завершающих пробелов
const TrimmedString = Schema.String.pipe(Schema.trimmed())
// Принимает: "hello", "hello world"
// Отвергает: " hello", "hello ", " hello "

// Lowercased
const LowercaseString = Schema.String.pipe(Schema.lowercased())

// Uppercased
const UppercaseString = Schema.String.pipe(Schema.uppercased())

// Includes — содержит подстроку
const ContainsAt = Schema.String.pipe(Schema.includes("@"))

// StartsWith / EndsWith
const HttpUrl = Schema.String.pipe(Schema.startsWith("https://"))
const DotCom = Schema.String.pipe(Schema.endsWith(".com"))

// UUID (встроенный)
const Id = Schema.UUID
// Эквивалент: Schema.String.pipe(Schema.pattern(/^[0-9a-fA-F]{8}-...$/))

// ULID (встроенный)
const TimeBasedId = Schema.ULID

Трансформации строк

Помимо проверок, Schema может трансформировать строки:

// Trim — автоматически убирает пробелы при decode
const TrimmedInput = Schema.Trim
// Type = string, Encoded = string
// decode("  hello  ") → "hello"
// encode("hello") → "hello"

// Lowercase
const NormalizedEmail = Schema.Lowercase
// decode("User@Example.COM") → "user@example.com"

// Compose — trim + lowercase
const CleanInput = Schema.Trim.pipe(Schema.compose(Schema.Lowercase))
// decode("  Hello World  ") → "hello world"

// Split — строка → массив
const CommaSeparated = Schema.split(",")
// Type = ReadonlyArray<string>, Encoded = string
// decode("a,b,c") → ["a", "b", "c"]

Доменные строковые типы: примеры

Вот как строковые рефайнменты используются для доменного моделирования Todo-приложения:

import { Schema } from "effect"

// ═══════════════════════════════════════
// Доменные строковые типы для Todo
// ═══════════════════════════════════════

/** Заголовок задачи: непустая строка до 255 символов, автоматический trim */
const TodoTitle = Schema.Trim.pipe(
  Schema.nonEmptyString(),
  Schema.maxLength(255),
  Schema.annotations({
    title: "TodoTitle",
    description: "Заголовок задачи — от 1 до 255 символов"
  })
)

/** Описание задачи: опциональное, до 5000 символов */
const TodoDescription = Schema.String.pipe(
  Schema.maxLength(5000),
  Schema.annotations({
    title: "TodoDescription",
    description: "Подробное описание задачи"
  })
)

/** Тег задачи: lowercase slug */
const TodoTag = Schema.Trim.pipe(
  Schema.compose(Schema.Lowercase),
  Schema.nonEmptyString(),
  Schema.maxLength(50),
  Schema.pattern(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
  Schema.annotations({
    title: "TodoTag",
    description: "Тег задачи в формате slug (например: work, high-priority)"
  })
)

Schema.Number — числовые типы

Базовая схема

const Age = Schema.Number
type Age = typeof Age.Type // number

Schema.decodeUnknownSync(Age)(42)      // 42
Schema.decodeUnknownSync(Age)("42")    // throws — строка не число!
Schema.decodeUnknownSync(Age)(NaN)     // NaN — ОСТОРОЖНО!

Важно: Schema.Number принимает NaN и Infinity. Если это нежелательно (а в домене это почти всегда нежелательно), используйте Schema.Finite или добавьте рефайнмент.

Рефайнменты для чисел

import { Schema } from "effect"

// Целое число
const Int = Schema.Number.pipe(Schema.int())
// Принимает: 1, 42, -7, 0
// Отвергает: 3.14, 2.5

// Положительное число
const Positive = Schema.Number.pipe(Schema.positive())
// Принимает: 1, 0.5, 100
// Отвергает: 0, -1

// Неотрицательное число
const NonNegative = Schema.Number.pipe(Schema.nonNegative())
// Принимает: 0, 1, 100
// Отвергает: -1, -0.5

// Отрицательное число
const Negative = Schema.Number.pipe(Schema.negative())

// Диапазон (inclusive)
const Percentage = Schema.Number.pipe(
  Schema.greaterThanOrEqualTo(0),
  Schema.lessThanOrEqualTo(100)
)

// Диапазон (exclusive)
const StrictlyBetween = Schema.Number.pipe(
  Schema.greaterThan(0),
  Schema.lessThan(100)
)

// Конечное число (не NaN, не Infinity)
const FiniteNumber = Schema.Number.pipe(Schema.finite())
// или просто:
const SafeNumber = Schema.Finite

// Between — удобный shortcut
const Rating = Schema.Number.pipe(
  Schema.between(1, 5)
)

// MultipleOf — кратность
const EvenNumber = Schema.Number.pipe(Schema.multipleOf(2))

Числовые трансформации

// NumberFromString — парсинг числа из строки
const Port = Schema.NumberFromString.pipe(
  Schema.int(),
  Schema.between(1, 65535)
)
// Type = number, Encoded = string
// decode("8080") → 8080
// decode("abc") → ParseError

// Clamp — ограничение диапазона (не отвергает, а обрезает)
const ClampedRating = Schema.Number.pipe(
  Schema.clamp(1, 5)
)
// decode(0) → 1    (clamped to min)
// decode(3) → 3    (unchanged)
// decode(10) → 5   (clamped to max)

Доменные числовые типы: примеры

import { Schema } from "effect"

/** Приоритет задачи (1-5, целое число) */
const PriorityLevel = Schema.Number.pipe(
  Schema.int(),
  Schema.between(1, 5),
  Schema.annotations({
    title: "PriorityLevel",
    description: "Числовой приоритет задачи от 1 (низкий) до 5 (критический)"
  })
)

/** Процент выполнения */
const CompletionPercent = Schema.Number.pipe(
  Schema.finite(),
  Schema.greaterThanOrEqualTo(0),
  Schema.lessThanOrEqualTo(100),
  Schema.annotations({
    title: "CompletionPercent",
    description: "Процент выполнения задачи (0-100)"
  })
)

/** Позиция в списке (целое неотрицательное) */
const SortOrder = Schema.Number.pipe(
  Schema.int(),
  Schema.nonNegative(),
  Schema.annotations({ title: "SortOrder" })
)

/** Максимальное количество задач в списке */
const MaxTodos = Schema.Number.pipe(
  Schema.int(),
  Schema.between(1, 1000),
  Schema.annotations({ title: "MaxTodos" })
)

Schema.Boolean — логический тип

Базовая схема

const IsActive = Schema.Boolean
type IsActive = typeof IsActive.Type // boolean

Schema.decodeUnknownSync(IsActive)(true)   // true
Schema.decodeUnknownSync(IsActive)(false)  // false
Schema.decodeUnknownSync(IsActive)(1)      // throws — не boolean!
Schema.decodeUnknownSync(IsActive)("true") // throws — не boolean!

Важно: Schema.Boolean строгий — принимает только true и false. Числа 0/1 и строки "true"/"false" — не boolean.

Трансформации для Boolean

Часто внешние системы представляют boolean не как true/false:

import { Schema } from "effect"

// Из числа: 0 → false, всё остальное → true
const BoolFromNumber = Schema.transform(
  Schema.Number,
  Schema.Boolean,
  {
    strict: true,
    decode: (n) => n !== 0,
    encode: (b) => (b ? 1 : 0)
  }
)
// decode(0) → false
// decode(1) → true
// encode(true) → 1
// encode(false) → 0

// Из строки "true"/"false"
const BoolFromString = Schema.transform(
  Schema.Literal("true", "false"),
  Schema.Boolean,
  {
    strict: true,
    decode: (s) => s === "true",
    encode: (b) => (b ? "true" as const : "false" as const)
  }
)

// SQLite-специфичный: 0/1 как INTEGER
const SqliteBool = Schema.transform(
  Schema.Literal(0, 1),
  Schema.Boolean,
  {
    strict: true,
    decode: (n) => n === 1,
    encode: (b) => (b ? 1 as const : 0 as const)
  }
)

Boolean в домене

/** Флаг завершения задачи */
const IsCompleted = Schema.Boolean.pipe(
  Schema.annotations({
    title: "IsCompleted",
    description: "Флаг завершения задачи"
  })
)

/** Флаг архивации */
const IsArchived = Schema.Boolean.pipe(
  Schema.annotations({
    title: "IsArchived",
    description: "Помечена ли задача как архивная"
  })
)

Литеральные типы и перечисления

Schema.Literal

Schema.Literal определяет схему, принимающую только указанные значения:

// Одно значение
const Admin = Schema.Literal("admin")
type Admin = typeof Admin.Type // "admin"

// Несколько значений — union
const Role = Schema.Literal("admin", "user", "guest")
type Role = typeof Role.Type // "admin" | "user" | "guest"

Schema.decodeUnknownSync(Role)("admin")    // "admin"
Schema.decodeUnknownSync(Role)("superadmin") // throws ParseError

// Числовые литералы
const HttpStatus = Schema.Literal(200, 201, 204, 400, 404, 500)
type HttpStatus = typeof HttpStatus.Type // 200 | 201 | 204 | 400 | 404 | 500

// Смешанные литералы
const SpecialValue = Schema.Literal("none", 0, false, null)
type SpecialValue = typeof SpecialValue.Type // "none" | 0 | false | null

Enums в домене через Literal

Для доменного моделирования Schema.Literal — идеальный способ определить перечисления:

import { Schema } from "effect"

// ═══════════════════════════════════════
// Статус задачи — ключевое перечисление домена
// ═══════════════════════════════════════
const TodoStatus = Schema.Literal("draft", "active", "completed", "archived")
type TodoStatus = typeof TodoStatus.Type
// "draft" | "active" | "completed" | "archived"

// ═══════════════════════════════════════
// Приоритет задачи
// ═══════════════════════════════════════
const Priority = Schema.Literal("low", "medium", "high", "critical")
type Priority = typeof Priority.Type
// "low" | "medium" | "high" | "critical"

// ═══════════════════════════════════════
// Тип уведомления
// ═══════════════════════════════════════
const NotificationType = Schema.Literal("email", "push", "sms", "in-app")
type NotificationType = typeof NotificationType.Type

Schema.Enums — для TypeScript enum

Если в проекте используются TypeScript enums (хотя в функциональном стиле предпочтительнее Literal):

enum Direction {
  Up = "UP",
  Down = "DOWN",
  Left = "LEFT",
  Right = "RIGHT"
}

const DirectionSchema = Schema.Enums(Direction)
type DirectionType = typeof DirectionSchema.Type // Direction

Schema.decodeUnknownSync(DirectionSchema)("UP") // Direction.Up
Schema.decodeUnknownSync(DirectionSchema)("DIAGONAL") // throws

Рекомендация: в функциональном стиле с Effect предпочитайте Schema.Literal вместо TypeScript enum. Literal даёт union type, который лучше интегрируется с pattern matching и функциональной композицией.


Специальные типы

Nullable и Optional

import { Schema } from "effect"

// Nullable — string | null
const NullableString = Schema.NullOr(Schema.String)
type NullableString = typeof NullableString.Type // string | null

Schema.decodeUnknownSync(NullableString)("hello") // "hello"
Schema.decodeUnknownSync(NullableString)(null)     // null
Schema.decodeUnknownSync(NullableString)(undefined) // throws!

// Undefined — string | undefined
const OptionalString = Schema.UndefinedOr(Schema.String)

// NullishOr — string | null | undefined
const NullishString = Schema.NullishOr(Schema.String)

Date

// Schema.Date — принимает Date объект
const EventDate = Schema.Date
Schema.decodeUnknownSync(EventDate)(new Date()) // Date
Schema.decodeUnknownSync(EventDate)("2024-01-01") // throws — не Date!

// Schema.DateFromString — трансформация string ↔ Date
const CreatedAt = Schema.DateFromString
// Type = Date, Encoded = string
Schema.decodeUnknownSync(CreatedAt)("2024-01-01T00:00:00Z") // Date
// Schema.encodeSync(CreatedAt)(new Date()) → "2024-01-01T00:00:00.000Z"

// DateFromNumber — timestamp ↔ Date
const Timestamp = Schema.DateFromNumber
// Type = Date, Encoded = number
Schema.decodeUnknownSync(Timestamp)(1704067200000) // Date

BigDecimal и BigInt

Для финансовых расчётов, где floating-point неточности недопустимы:

import { Schema } from "effect"

// BigDecimal из числа
const Price = Schema.BigDecimalFromNumber
// Type = BigDecimal, Encoded = number

// BigDecimal из строки
const ExactAmount = Schema.BigDecimal
// Type = BigDecimal, Encoded = string

// BigInt
const LargeId = Schema.BigInt
// Type = bigint, Encoded = string

Кастомные рефайнменты через Schema.filter

Когда встроенных рефайнментов недостаточно, используем Schema.filter:

import { Schema } from "effect"

// Простой фильтр с boolean
const EvenNumber = Schema.Number.pipe(
  Schema.filter((n) => n % 2 === 0, {
    message: () => "Число должно быть чётным"
  })
)

// Фильтр с кастомным сообщением об ошибке
const StrongPassword = Schema.String.pipe(
  Schema.minLength(8),
  Schema.filter(
    (s) => /[A-Z]/.test(s) && /[a-z]/.test(s) && /[0-9]/.test(s),
    {
      message: () =>
        "Пароль должен содержать заглавную букву, строчную букву и цифру"
    }
  )
)

// Фильтр с детальной ошибкой (ParseResult.Type)
const ValidDateRange = Schema.Struct({
  start: Schema.DateFromString,
  end: Schema.DateFromString
}).pipe(
  Schema.filter(
    ({ start, end }) => end > start,
    {
      message: () => "Дата окончания должна быть позже даты начала"
    }
  )
)

// Фильтр, возвращающий массив ошибок
const ComplexValidation = Schema.String.pipe(
  Schema.filter((s) => {
    const issues: Array<Schema.FilterIssue> = []
    if (s.length < 8) {
      issues.push({ path: [], message: "Минимальная длина — 8 символов" })
    }
    if (!/[A-Z]/.test(s)) {
      issues.push({ path: [], message: "Нужна хотя бы одна заглавная буква" })
    }
    if (!/[0-9]/.test(s)) {
      issues.push({ path: [], message: "Нужна хотя бы одна цифра" })
    }
    return issues.length === 0 ? true : issues
  })
)

Типизированный фильтр с brand

Часто фильтр сочетают с brand, чтобы создать номинальный тип:

import { Schema } from "effect"

// Email — строка + валидация + brand
const Email = Schema.String.pipe(
  Schema.trimmed(),
  Schema.lowercased(),
  Schema.nonEmptyString(),
  Schema.maxLength(254),
  Schema.pattern(/^[^@\s]+@[^@\s]+\.[^@\s]+$/),
  Schema.brand("Email"),
  Schema.annotations({
    title: "Email",
    description: "Валидный email-адрес"
  })
)
type Email = typeof Email.Type
// string & Brand<"Email">

// Теперь Email — это отдельный тип
const sendNotification = (email: Email): void => {
  // email гарантированно валиден
  console.log(`Sending to ${email}`)
}

// ❌ Ошибка компиляции
sendNotification("not-validated@email.com")
// Type 'string' is not assignable to type 'string & Brand<"Email">'

// ✅ Только через Schema.decode
const validEmail = Schema.decodeUnknownSync(Email)("user@example.com")
sendNotification(validEmail) // OK

Аннотации: метаданные для Schema

Каждая Schema может быть аннотирована метаданными, которые полезны для документации, генерации OpenAPI, отображения ошибок:

import { Schema } from "effect"

const TodoTitle = Schema.String.pipe(
  Schema.nonEmptyString(),
  Schema.maxLength(255),
  Schema.annotations({
    // Стандартные аннотации
    title: "TodoTitle",
    description: "Заголовок задачи — непустая строка до 255 символов",
    examples: ["Купить молоко", "Подготовить отчёт за Q3"],

    // Кастомные аннотации (JSON Schema)
    jsonSchema: {
      minLength: 1,
      maxLength: 255
    },

    // Сообщение об ошибке
    message: () => "Заголовок задачи должен быть от 1 до 255 символов"
  })
)

Аннотации не влияют на валидацию, но используются:

  • В сообщениях об ошибках (message)
  • При генерации JSON Schema (jsonSchema)
  • При генерации OpenAPI спецификации
  • При генерации документации
  • В тестах (examples)

Unknown, Any, Void, Never

// Unknown — принимает любое значение, тип unknown
const AnyValue = Schema.Unknown
type AnyValue = typeof AnyValue.Type // unknown

// Any — аналогично, но тип any (менее безопасно)
const Unsafe = Schema.Any
type Unsafe = typeof Unsafe.Type // any

// Void — для функций, не возвращающих значение
const NoReturn = Schema.Void
type NoReturn = typeof NoReturn.Type // void

// Never — схема, которая отвергает всё
const Impossible = Schema.Never
// Schema.decodeUnknownSync(Impossible)(anything) — всегда throws

// Undefined
const Undef = Schema.Undefined
Schema.decodeUnknownSync(Undef)(undefined) // undefined
Schema.decodeUnknownSync(Undef)(null)      // throws

// Null
const Nul = Schema.Null
Schema.decodeUnknownSync(Nul)(null)      // null
Schema.decodeUnknownSync(Nul)(undefined) // throws

Коллекции: Array, ReadonlyArray, Set, Map

import { Schema } from "effect"

// ═══════════════════════════════════════
// Массивы
// ═══════════════════════════════════════

// ReadonlyArray — иммутабельный массив (предпочтительно)
const Tags = Schema.Array(Schema.String)
type Tags = typeof Tags.Type // ReadonlyArray<string>

// С рефайнментами на элементы
const Priorities = Schema.Array(
  Schema.Literal("low", "medium", "high")
)
type Priorities = typeof Priorities.Type
// ReadonlyArray<"low" | "medium" | "high">

// С рефайнментами на массив
const NonEmptyTags = Schema.NonEmptyArray(Schema.String)
type NonEmptyTags = typeof NonEmptyTags.Type
// readonly [string, ...Array<string>]

// Ограничения на длину массива
const LimitedTags = Schema.Array(Schema.String).pipe(
  Schema.minItems(1),
  Schema.maxItems(10)
)

// ═══════════════════════════════════════
// Кортежи (Tuples)
// ═══════════════════════════════════════

// Фиксированная структура
const Point2D = Schema.Tuple(Schema.Number, Schema.Number)
type Point2D = typeof Point2D.Type // readonly [number, number]

const NameAge = Schema.Tuple(Schema.String, Schema.Number)
type NameAge = typeof NameAge.Type // readonly [string, number]

// Кортеж с rest
const StringWithNumbers = Schema.Tuple(
  [Schema.String],       // первый элемент — string
  Schema.Number          // остальные — number
)
type StringWithNumbers = typeof StringWithNumbers.Type
// readonly [string, ...Array<number>]

// ═══════════════════════════════════════
// Record (словарь)
// ═══════════════════════════════════════

const ScoresByPlayer = Schema.Record({
  key: Schema.String,
  value: Schema.Number
})
type ScoresByPlayer = typeof ScoresByPlayer.Type
// { readonly [x: string]: number }

// С ограниченным ключом
const StatusCounts = Schema.Record({
  key: Schema.Literal("active", "completed", "archived"),
  value: Schema.Number
})

// ═══════════════════════════════════════
// Set и Map (через трансформации)
// ═══════════════════════════════════════

// HashSet из массива
const UniqueTagsFromArray = Schema.HashSetFromSelf(Schema.String)

// HashMap
const SettingsMap = Schema.HashMapFromSelf({
  key: Schema.String,
  value: Schema.Unknown
})

Объединения и пересечения

Union — “или”

import { Schema } from "effect"

// Простой union
const StringOrNumber = Schema.Union(Schema.String, Schema.Number)
type StringOrNumber = typeof StringOrNumber.Type // string | number

// Дискриминированный union (tagged union)
const Shape = Schema.Union(
  Schema.Struct({
    _tag: Schema.Literal("Circle"),
    radius: Schema.Number.pipe(Schema.positive())
  }),
  Schema.Struct({
    _tag: Schema.Literal("Rectangle"),
    width: Schema.Number.pipe(Schema.positive()),
    height: Schema.Number.pipe(Schema.positive())
  }),
  Schema.Struct({
    _tag: Schema.Literal("Triangle"),
    base: Schema.Number.pipe(Schema.positive()),
    height: Schema.Number.pipe(Schema.positive())
  })
)
type Shape = typeof Shape.Type
// { _tag: "Circle"; radius: number }
// | { _tag: "Rectangle"; width: number; height: number }
// | { _tag: "Triangle"; base: number; height: number }

// Discriminated union с дискриминантом
// Schema автоматически определяет _tag как дискриминант
const decoded = Schema.decodeUnknownSync(Shape)({
  _tag: "Circle",
  radius: 5
})
// { _tag: "Circle", radius: 5 }

Optional fields в Struct

import { Schema } from "effect"

const Todo = Schema.Struct({
  id: Schema.String,
  title: Schema.String,

  // optional — поле может отсутствовать (undefined)
  description: Schema.optional(Schema.String),

  // optionalWith с exact: true — строго undefined, не missing
  notes: Schema.optional(Schema.String, { exact: true }),

  // optionalWith default — значение по умолчанию при decode
  completed: Schema.optional(Schema.Boolean, { default: () => false }),

  // optionalWith nullable
  dueDate: Schema.optional(Schema.DateFromString, { nullable: true }),
})

type Todo = typeof Todo.Type
// {
//   readonly id: string
//   readonly title: string
//   readonly description?: string | undefined
//   readonly notes?: string | undefined
//   readonly completed: boolean          ← default превращает optional в required!
//   readonly dueDate?: Date | null | undefined
// }

Паттерны композиции базовых схем

Pipe — последовательное уточнение

import { Schema } from "effect"

// pipe — основной способ композиции рефайнментов
const SafePositiveInt = Schema.Number.pipe(
  Schema.finite(),      // шаг 1: исключить NaN, Infinity
  Schema.int(),         // шаг 2: только целые
  Schema.positive(),    // шаг 3: только положительные
  Schema.lessThan(2147483647) // шаг 4: вписывается в int32
)
// Каждый рефайнмент добавляет проверку, сужая допустимые значения

Вынесение общих паттернов

import { Schema } from "effect"

// ═══════════════════════════════════════
// Переиспользуемые базовые схемы
// ═══════════════════════════════════════

/** Непустая строка с автоматическим trim */
const CleanString = Schema.Trim.pipe(
  Schema.nonEmptyString()
)

/** Ограниченная строка */
const boundedString = (min: number, max: number) =>
  CleanString.pipe(
    Schema.minLength(min),
    Schema.maxLength(max)
  )

/** Положительное целое число */
const PositiveInt = Schema.Number.pipe(
  Schema.int(),
  Schema.positive()
)

/** Неотрицательное целое */
const NonNegativeInt = Schema.Number.pipe(
  Schema.int(),
  Schema.nonNegative()
)

/** ISO 8601 дата-время */
const ISODateTime = Schema.DateFromString

// ═══════════════════════════════════════
// Использование в доменных типах
// ═══════════════════════════════════════

const TodoTitle = boundedString(1, 255).pipe(
  Schema.brand("TodoTitle"),
  Schema.annotations({ title: "TodoTitle" })
)

const TodoDescription = boundedString(1, 5000).pipe(
  Schema.annotations({ title: "TodoDescription" })
)

const MaxItems = PositiveInt.pipe(
  Schema.lessThanOrEqualTo(1000),
  Schema.annotations({ title: "MaxItems" })
)

Итоги главы

Базовые схемы — это атомы доменного моделирования. Ключевые принципы:

  1. Сужайте примитивы: не используйте голый string — уточните длину, паттерн, формат
  2. Композируйте через pipe: каждый рефайнмент — это шаг сужения от широкого типа к узкому
  3. Используйте brand для номинальной типизации: Emailstring, даже если runtime-значение — строка
  4. Выносите общие паттерны: boundedString(min, max), PositiveInt — переиспользуемые строительные блоки
  5. Аннотируйте: title, description, examples — для документации и сообщений об ошибках
  6. Помните о двойственности Type/Encoded: DateFromString декодирует string в Date и обратно

В следующей главе мы перейдём от отдельных примитивов к составным структурам — Schema.Struct и Schema.Class, которые позволяют моделировать полноценные сущности домена.