Типобезопасный домен: Гексагональная архитектура на базе Effect Упражнения: TodoTitle, Priority, DueDate как Value Objects
Глава

Упражнения: TodoTitle, Priority, DueDate как Value Objects

Практические упражнения по созданию Value Objects для Todo-приложения. TodoId (branded UUID), TodoTitle (нормализованная строка), Priority (литеральное перечисление с весами и операциями), DueDate (дата с операциями isOverdue, daysUntil). Эталонные решения с полными тестами. Интеграционный пример сборки Todo Entity из VO. Чеклист правильного Value Object.

Введение

В этом практическом модуле мы применим все знания о Value Objects для создания реальных Value Objects нашего Todo-приложения. Для каждого VO мы предоставим:

  1. Задание — что нужно реализовать
  2. Подсказки — на что обратить внимание
  3. Эталонное решение — полная реализация с комментариями
  4. Тесты — проверка корректности

Упражнение 1: TodoId

Задание

Создайте Value Object TodoId — уникальный идентификатор задачи:

  • UUID v4 формат
  • Branded type (нельзя перепутать с UserId, ProjectId)
  • Фабричная функция generateTodoId для создания новых id
  • Smart constructor с валидацией

Эталонное решение

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

// ============================================================
// TodoId — уникальный идентификатор задачи
// ============================================================

const UUID_V4_REGEX =
  /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i

/** Schema для TodoId с валидацией UUID v4 формата */
export const TodoId = Schema.String.pipe(
  Schema.pattern(UUID_V4_REGEX, {
    message: () => "TodoId must be a valid UUID v4"
  }),
  Schema.brand("TodoId"),
  Schema.annotations({
    identifier: "TodoId",
    title: "Todo Identifier",
    description: "Unique UUID v4 identifier for a Todo entity"
  })
)

/** Тип TodoId — string & Brand<"TodoId"> */
export type TodoId = typeof TodoId.Type

// === Smart Constructors ===

/** Создать TodoId из строки (Effect) */
export const decodeTodoId = Schema.decodeUnknown(TodoId)

/** Создать TodoId из строки (синхронно, бросает при ошибке) */
export const decodeTodoIdSync = Schema.decodeUnknownSync(TodoId)

/** Проверить строку на соответствие TodoId (Either) */
export const parseTodoId = Schema.decodeUnknownEither(TodoId)

/** Сгенерировать новый TodoId */
export const generateTodoId = (): TodoId =>
  crypto.randomUUID() as TodoId

Тесты

import { describe, it, expect } from "bun:test"
import { Either } from "effect"
import { TodoId, decodeTodoIdSync, parseTodoId, generateTodoId } from "./TodoId"

describe("TodoId", () => {
  it("accepts valid UUID v4", () => {
    const id = decodeTodoIdSync("550e8400-e29b-41d4-a716-446655440000")
    expect(typeof id).toBe("string")
  })

  it("rejects invalid format", () => {
    const result = parseTodoId("not-a-uuid")
    expect(Either.isLeft(result)).toBe(true)
  })

  it("rejects empty string", () => {
    const result = parseTodoId("")
    expect(Either.isLeft(result)).toBe(true)
  })

  it("rejects UUID v1 (different version digit)", () => {
    const result = parseTodoId("550e8400-e29b-11d4-a716-446655440000")
    expect(Either.isLeft(result)).toBe(true)
  })

  it("generates valid TodoId", () => {
    const id = generateTodoId()
    const result = parseTodoId(id)
    expect(Either.isRight(result)).toBe(true)
  })

  it("generates unique ids", () => {
    const ids = Array.from({ length: 100 }, generateTodoId)
    const unique = new Set(ids)
    expect(unique.size).toBe(100)
  })

  it("branded type prevents mixing with plain string", () => {
    const id = generateTodoId()
    // TypeScript не позволит:
    // const fn = (x: string) => x
    // fn(id) // OK — TodoId extends string
    // Но:
    // const fn2 = (x: TodoId) => x
    // fn2("plain-string") // ❌ Type error!
    expect(id).toBeDefined()
  })
})

Упражнение 2: TodoTitle

Задание

Создайте Value Object TodoTitle — заголовок задачи:

  • Непустая строка от 1 до 200 символов
  • Автоматический trim при создании
  • Нормализация множественных пробелов (замена на один)
  • Запрет только из пробелов
  • Операции: truncate, contains, wordCount

Эталонное решение

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

// ============================================================
// TodoTitle — заголовок задачи
// ============================================================

const MIN_LENGTH = 1
const MAX_LENGTH = 200

/** Нормализация строки: trim + удаление лишних пробелов */
const normalizeTitle = (raw: string): string =>
  raw.trim().replace(/\s+/g, " ")

/** Schema для нормализованного заголовка */
const NormalizedTitle = Schema.String.pipe(
  Schema.minLength(MIN_LENGTH, {
    message: () => "Заголовок задачи не может быть пустым"
  }),
  Schema.maxLength(MAX_LENGTH, {
    message: () => `Заголовок задачи не может превышать ${MAX_LENGTH} символов`
  }),
  Schema.brand("TodoTitle")
)

/** TodoTitle Schema с автоматической нормализацией */
export const TodoTitle = Schema.transform(
  Schema.String,
  NormalizedTitle,
  {
    strict: true,
    decode: (raw) => normalizeTitle(raw),
    encode: (title) => title as string
  }
).pipe(
  Schema.annotations({
    identifier: "TodoTitle",
    title: "Todo Title",
    description: `Заголовок задачи: ${MIN_LENGTH}-${MAX_LENGTH} символов, автотрим`
  })
)

/** Тип TodoTitle */
export type TodoTitle = typeof TodoTitle.Type

// === Smart Constructors ===

export const createTodoTitle = Schema.decodeUnknown(TodoTitle)
export const createTodoTitleSync = Schema.decodeUnknownSync(TodoTitle)
export const parseTodoTitle = Schema.decodeUnknownEither(TodoTitle)

// === Операции (чистые функции) ===

/** Обрезать заголовок до указанной длины */
export const truncateTitle = (title: TodoTitle, maxLength: number): string => {
  const str = title as string
  return str.length <= maxLength
    ? str
    : str.slice(0, maxLength - 3) + "..."
}

/** Проверить, содержит ли заголовок подстроку (case-insensitive) */
export const titleContains = (title: TodoTitle, query: string): boolean =>
  (title as string).toLowerCase().includes(query.toLowerCase())

/** Количество слов в заголовке */
export const titleWordCount = (title: TodoTitle): number =>
  (title as string).split(/\s+/).length

/** Длина заголовка в символах */
export const titleLength = (title: TodoTitle): number =>
  (title as string).length

Тесты

import { describe, it, expect } from "bun:test"
import { Either } from "effect"
import {
  createTodoTitleSync, parseTodoTitle,
  truncateTitle, titleContains, titleWordCount, titleLength
} from "./TodoTitle"

describe("TodoTitle", () => {

  describe("creation", () => {
    it("creates from valid string", () => {
      const title = createTodoTitleSync("Buy groceries")
      expect(title as string).toBe("Buy groceries")
    })

    it("trims whitespace", () => {
      const title = createTodoTitleSync("  Buy groceries  ")
      expect(title as string).toBe("Buy groceries")
    })

    it("normalizes multiple spaces", () => {
      const title = createTodoTitleSync("Buy   many    groceries")
      expect(title as string).toBe("Buy many groceries")
    })

    it("rejects empty string", () => {
      const result = parseTodoTitle("")
      expect(Either.isLeft(result)).toBe(true)
    })

    it("rejects whitespace-only string", () => {
      const result = parseTodoTitle("   ")
      expect(Either.isLeft(result)).toBe(true)
    })

    it("rejects string exceeding 200 chars", () => {
      const result = parseTodoTitle("a".repeat(201))
      expect(Either.isLeft(result)).toBe(true)
    })

    it("accepts string of exactly 200 chars", () => {
      const result = parseTodoTitle("a".repeat(200))
      expect(Either.isRight(result)).toBe(true)
    })

    it("accepts single character", () => {
      const result = parseTodoTitle("X")
      expect(Either.isRight(result)).toBe(true)
    })
  })

  describe("operations", () => {
    const title = createTodoTitleSync("Buy groceries for dinner")

    it("truncates long title", () => {
      expect(truncateTitle(title, 15)).toBe("Buy grocer...")
    })

    it("does not truncate short enough title", () => {
      expect(truncateTitle(title, 100)).toBe("Buy groceries for dinner")
    })

    it("checks contains (case-insensitive)", () => {
      expect(titleContains(title, "groceries")).toBe(true)
      expect(titleContains(title, "GROCERIES")).toBe(true)
      expect(titleContains(title, "homework")).toBe(false)
    })

    it("counts words", () => {
      expect(titleWordCount(title)).toBe(4)
    })

    it("measures length", () => {
      expect(titleLength(title)).toBe(24)
    })
  })
})

Упражнение 3: Priority

Задание

Создайте Value Object Priority — приоритет задачи:

  • Фиксированные значения: low, medium, high, critical
  • Числовой вес для сортировки
  • Функции сравнения: comparePriority, isUrgent, isHigherThan
  • Функция nextPriority — повышение приоритета на один уровень

Эталонное решение

import { Schema, Order } from "effect"

// ============================================================
// Priority — приоритет задачи
// ============================================================

/** Допустимые уровни приоритета */
export const Priority = Schema.Literal("low", "medium", "high", "critical").pipe(
  Schema.annotations({
    identifier: "Priority",
    title: "Task Priority",
    description: "Priority level: low | medium | high | critical"
  })
)

/** Тип Priority */
export type Priority = typeof Priority.Type

// === Константы ===

/** Все приоритеты в порядке возрастания */
export const ALL_PRIORITIES: ReadonlyArray<Priority> = [
  "low", "medium", "high", "critical"
] as const

/** Приоритет по умолчанию */
export const DEFAULT_PRIORITY: Priority = "medium"

// === Числовые веса ===

const PRIORITY_WEIGHTS: Record<Priority, number> = {
  low: 1,
  medium: 2,
  high: 3,
  critical: 4
} as const

/** Числовой вес приоритета (для сортировки) */
export const priorityWeight = (priority: Priority): number =>
  PRIORITY_WEIGHTS[priority]

// === Сравнение ===

/** Компаратор для сортировки: отрицательный = a < b */
export const comparePriority = (a: Priority, b: Priority): number =>
  priorityWeight(a) - priorityWeight(b)

/** Order instance для Effect sorting */
export const PriorityOrder: Order.Order<Priority> =
  Order.make(comparePriority)

/** a выше b по приоритету? */
export const isHigherThan = (a: Priority, b: Priority): boolean =>
  priorityWeight(a) > priorityWeight(b)

/** a ниже b по приоритету? */
export const isLowerThan = (a: Priority, b: Priority): boolean =>
  priorityWeight(a) < priorityWeight(b)

// === Классификация ===

/** Является ли приоритет срочным (high или critical) */
export const isUrgent = (priority: Priority): boolean =>
  priority === "high" || priority === "critical"

/** Является ли приоритет низким */
export const isLowPriority = (priority: Priority): boolean =>
  priority === "low"

// === Трансформации ===

/** Повысить приоритет на один уровень (critical остаётся critical) */
export const escalate = (priority: Priority): Priority => {
  const index = ALL_PRIORITIES.indexOf(priority)
  return ALL_PRIORITIES[Math.min(index + 1, ALL_PRIORITIES.length - 1)]!
}

/** Понизить приоритет на один уровень (low остаётся low) */
export const deescalate = (priority: Priority): Priority => {
  const index = ALL_PRIORITIES.indexOf(priority)
  return ALL_PRIORITIES[Math.max(index - 1, 0)]!
}

// === Отображение ===

const PRIORITY_LABELS: Record<Priority, string> = {
  low: "Низкий",
  medium: "Средний",
  high: "Высокий",
  critical: "Критический"
} as const

const PRIORITY_EMOJI: Record<Priority, string> = {
  low: "🟢",
  medium: "🟡",
  high: "🟠",
  critical: "🔴"
} as const

/** Человекочитаемая метка */
export const priorityLabel = (priority: Priority): string =>
  PRIORITY_LABELS[priority]

/** Эмодзи для приоритета */
export const priorityEmoji = (priority: Priority): string =>
  PRIORITY_EMOJI[priority]

/** Полное отображение: "🔴 Критический" */
export const priorityDisplay = (priority: Priority): string =>
  `${priorityEmoji(priority)} ${priorityLabel(priority)}`

Тесты

import { describe, it, expect } from "bun:test"
import { Schema, Either } from "effect"
import {
  Priority, ALL_PRIORITIES, DEFAULT_PRIORITY,
  priorityWeight, comparePriority, isHigherThan,
  isUrgent, escalate, deescalate, priorityLabel
} from "./Priority"

describe("Priority", () => {

  describe("schema validation", () => {
    it("accepts valid priorities", () => {
      for (const p of ALL_PRIORITIES) {
        const result = Schema.decodeUnknownEither(Priority)(p)
        expect(Either.isRight(result)).toBe(true)
      }
    })

    it("rejects invalid priority", () => {
      const result = Schema.decodeUnknownEither(Priority)("invalid")
      expect(Either.isLeft(result)).toBe(true)
    })

    it("rejects empty string", () => {
      const result = Schema.decodeUnknownEither(Priority)("")
      expect(Either.isLeft(result)).toBe(true)
    })
  })

  describe("weights and comparison", () => {
    it("low < medium < high < critical", () => {
      expect(priorityWeight("low")).toBeLessThan(priorityWeight("medium"))
      expect(priorityWeight("medium")).toBeLessThan(priorityWeight("high"))
      expect(priorityWeight("high")).toBeLessThan(priorityWeight("critical"))
    })

    it("comparePriority for sorting", () => {
      const priorities: Priority[] = ["critical", "low", "high", "medium"]
      const sorted = [...priorities].sort(comparePriority)
      expect(sorted).toEqual(["low", "medium", "high", "critical"])
    })

    it("isHigherThan", () => {
      expect(isHigherThan("high", "low")).toBe(true)
      expect(isHigherThan("low", "high")).toBe(false)
      expect(isHigherThan("high", "high")).toBe(false)
    })
  })

  describe("classification", () => {
    it("urgent = high | critical", () => {
      expect(isUrgent("critical")).toBe(true)
      expect(isUrgent("high")).toBe(true)
      expect(isUrgent("medium")).toBe(false)
      expect(isUrgent("low")).toBe(false)
    })
  })

  describe("transformations", () => {
    it("escalate raises priority by one level", () => {
      expect(escalate("low")).toBe("medium")
      expect(escalate("medium")).toBe("high")
      expect(escalate("high")).toBe("critical")
    })

    it("escalate caps at critical", () => {
      expect(escalate("critical")).toBe("critical")
    })

    it("deescalate lowers priority by one level", () => {
      expect(deescalate("critical")).toBe("high")
      expect(deescalate("high")).toBe("medium")
      expect(deescalate("medium")).toBe("low")
    })

    it("deescalate floors at low", () => {
      expect(deescalate("low")).toBe("low")
    })
  })

  describe("display", () => {
    it("has label for each priority", () => {
      for (const p of ALL_PRIORITIES) {
        expect(priorityLabel(p).length).toBeGreaterThan(0)
      }
    })
  })
})

Упражнение 4: DueDate

Задание

Создайте Value Object DueDate — срок выполнения задачи:

  • Дата без времени (только год-месяц-день)
  • Не может быть в прошлом при создании (опционально — зависит от контекста)
  • Операции: isOverdue, daysUntil, isToday, isTomorrow
  • Сериализация: ISO 8601 date string ("2024-12-31")

Эталонное решение

import { Schema, Either, Option, pipe } from "effect"

// ============================================================
// DueDate — срок выполнения задачи
// ============================================================

/** Формат даты: YYYY-MM-DD */
const DATE_REGEX = /^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])$/

/**
 * DueDate Value Object
 *
 * Представляет дату без времени.
 * Сериализуется в ISO 8601 date string: "2024-12-31"
 */
export const DueDate = Schema.String.pipe(
  Schema.pattern(DATE_REGEX, {
    message: () => "DueDate must be in YYYY-MM-DD format"
  }),
  Schema.filter((dateStr) => {
    const date = new Date(dateStr + "T00:00:00Z")
    return isNaN(date.getTime())
      ? "DueDate is not a valid calendar date"
      : undefined
  }),
  Schema.brand("DueDate"),
  Schema.annotations({
    identifier: "DueDate",
    title: "Due Date",
    description: "Task due date in YYYY-MM-DD format"
  })
)

/** Тип DueDate */
export type DueDate = typeof DueDate.Type

// === Smart Constructors ===

export const createDueDate = Schema.decodeUnknown(DueDate)
export const createDueDateSync = Schema.decodeUnknownSync(DueDate)
export const parseDueDate = Schema.decodeUnknownEither(DueDate)

/** Создать DueDate из компонентов */
export const dueDateFrom = (year: number, month: number, day: number): DueDate =>
  createDueDateSync(
    `${year.toString().padStart(4, "0")}-${month.toString().padStart(2, "0")}-${day.toString().padStart(2, "0")}`
  )

/** DueDate для сегодня */
export const today = (): DueDate => {
  const now = new Date()
  return dueDateFrom(now.getFullYear(), now.getMonth() + 1, now.getDate())
}

/** DueDate для завтра */
export const tomorrow = (): DueDate => {
  const d = new Date()
  d.setDate(d.getDate() + 1)
  return dueDateFrom(d.getFullYear(), d.getMonth() + 1, d.getDate())
}

/** DueDate через N дней от сегодня */
export const daysFromNow = (days: number): DueDate => {
  const d = new Date()
  d.setDate(d.getDate() + days)
  return dueDateFrom(d.getFullYear(), d.getMonth() + 1, d.getDate())
}

// === Конвертация ===

/** Преобразовать DueDate в JavaScript Date (UTC midnight) */
export const toDate = (dueDate: DueDate): Date =>
  new Date((dueDate as string) + "T00:00:00Z")

/** Получить компоненты даты */
export const toComponents = (dueDate: DueDate): {
  readonly year: number
  readonly month: number
  readonly day: number
} => {
  const [year, month, day] = (dueDate as string).split("-").map(Number) as [number, number, number]
  return { year, month, day }
}

// === Операции (чистые функции) ===

/** Количество дней до срока (отрицательное = просрочено) */
export const daysUntil = (dueDate: DueDate): number => {
  const due = toDate(dueDate)
  const now = new Date()
  const todayMidnight = new Date(
    Date.UTC(now.getFullYear(), now.getMonth(), now.getDate())
  )
  return Math.ceil(
    (due.getTime() - todayMidnight.getTime()) / (1000 * 60 * 60 * 24)
  )
}

/** Просрочена ли задача */
export const isOverdue = (dueDate: DueDate): boolean =>
  daysUntil(dueDate) < 0

/** Срок — сегодня */
export const isToday = (dueDate: DueDate): boolean =>
  daysUntil(dueDate) === 0

/** Срок — завтра */
export const isTomorrow = (dueDate: DueDate): boolean =>
  daysUntil(dueDate) === 1

/** Срок — на этой неделе (в ближайшие 7 дней) */
export const isThisWeek = (dueDate: DueDate): boolean => {
  const days = daysUntil(dueDate)
  return days >= 0 && days <= 7
}

/** Срок — в будущем (не просрочена и не сегодня) */
export const isFuture = (dueDate: DueDate): boolean =>
  daysUntil(dueDate) > 0

// === Сравнение ===

/** Компаратор для сортировки */
export const compareDueDate = (a: DueDate, b: DueDate): number =>
  (a as string).localeCompare(b as string)

/** a раньше b */
export const isBefore = (a: DueDate, b: DueDate): boolean =>
  (a as string) < (b as string)

/** a позже b */
export const isAfter = (a: DueDate, b: DueDate): boolean =>
  (a as string) > (b as string)

/** a и b — один день */
export const isSameDay = (a: DueDate, b: DueDate): boolean =>
  (a as string) === (b as string)

// === Отображение ===

/** Человекочитаемое отображение */
export const formatDueDate = (dueDate: DueDate): string => {
  const days = daysUntil(dueDate)
  if (days < -1) return `Просрочено на ${Math.abs(days)} дн.`
  if (days === -1) return "Просрочено на 1 день"
  if (days === 0) return "Сегодня"
  if (days === 1) return "Завтра"
  if (days <= 7) return `Через ${days} дн.`
  return toDate(dueDate).toLocaleDateString("ru-RU", {
    year: "numeric",
    month: "long",
    day: "numeric"
  })
}

/** ISO строка (для сериализации) */
export const toISOString = (dueDate: DueDate): string =>
  dueDate as string

Тесты

import { describe, it, expect } from "bun:test"
import { Either } from "effect"
import {
  createDueDateSync, parseDueDate, dueDateFrom, today, tomorrow,
  daysFromNow, daysUntil, isOverdue, isToday, isTomorrow,
  isBefore, isAfter, isSameDay, toComponents, formatDueDate
} from "./DueDate"

describe("DueDate", () => {

  describe("creation", () => {
    it("creates from valid date string", () => {
      const dd = createDueDateSync("2025-06-15")
      expect(dd as string).toBe("2025-06-15")
    })

    it("rejects invalid format", () => {
      expect(Either.isLeft(parseDueDate("15-06-2025"))).toBe(true)
      expect(Either.isLeft(parseDueDate("2025/06/15"))).toBe(true)
      expect(Either.isLeft(parseDueDate("not-a-date"))).toBe(true)
    })

    it("rejects invalid calendar date", () => {
      expect(Either.isLeft(parseDueDate("2025-02-30"))).toBe(true)
      expect(Either.isLeft(parseDueDate("2025-13-01"))).toBe(true)
    })

    it("creates from components", () => {
      const dd = dueDateFrom(2025, 6, 15)
      expect(dd as string).toBe("2025-06-15")
    })

    it("creates today", () => {
      const dd = today()
      const now = new Date()
      const expected = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`
      expect(dd as string).toBe(expected)
    })

    it("creates daysFromNow", () => {
      const dd = daysFromNow(7)
      expect(daysUntil(dd)).toBe(7)
    })
  })

  describe("operations", () => {
    it("daysUntil for today is 0", () => {
      expect(daysUntil(today())).toBe(0)
    })

    it("daysUntil for tomorrow is 1", () => {
      expect(daysUntil(tomorrow())).toBe(1)
    })

    it("isOverdue for past date", () => {
      const pastDate = dueDateFrom(2020, 1, 1)
      expect(isOverdue(pastDate)).toBe(true)
    })

    it("isToday", () => {
      expect(isToday(today())).toBe(true)
      expect(isToday(tomorrow())).toBe(false)
    })

    it("isTomorrow", () => {
      expect(isTomorrow(tomorrow())).toBe(true)
      expect(isTomorrow(today())).toBe(false)
    })
  })

  describe("comparison", () => {
    const jan1 = dueDateFrom(2025, 1, 1)
    const jun15 = dueDateFrom(2025, 6, 15)
    const dec31 = dueDateFrom(2025, 12, 31)

    it("isBefore", () => {
      expect(isBefore(jan1, jun15)).toBe(true)
      expect(isBefore(dec31, jun15)).toBe(false)
    })

    it("isAfter", () => {
      expect(isAfter(dec31, jun15)).toBe(true)
      expect(isAfter(jan1, jun15)).toBe(false)
    })

    it("isSameDay", () => {
      expect(isSameDay(jan1, dueDateFrom(2025, 1, 1))).toBe(true)
      expect(isSameDay(jan1, jun15)).toBe(false)
    })
  })

  describe("components", () => {
    it("extracts year, month, day", () => {
      const { year, month, day } = toComponents(dueDateFrom(2025, 6, 15))
      expect(year).toBe(2025)
      expect(month).toBe(6)
      expect(day).toBe(15)
    })
  })

  describe("formatting", () => {
    it("formats today", () => {
      expect(formatDueDate(today())).toBe("Сегодня")
    })

    it("formats tomorrow", () => {
      expect(formatDueDate(tomorrow())).toBe("Завтра")
    })

    it("formats overdue", () => {
      const past = dueDateFrom(2020, 1, 1)
      expect(formatDueDate(past)).toContain("Просрочено")
    })
  })
})

Упражнение 5: Собираем всё вместе

Задание

Создайте barrel-файл index.ts и убедитесь, что все Value Objects работают вместе в контексте Todo Entity:

Эталонное решение

// domain/value-objects/index.ts
export { TodoId, type TodoId, generateTodoId, decodeTodoId } from "./TodoId"
export { TodoTitle, type TodoTitle, createTodoTitle, createTodoTitleSync } from "./TodoTitle"
export { Priority, type Priority, ALL_PRIORITIES, DEFAULT_PRIORITY,
         comparePriority, isUrgent, escalate, priorityDisplay } from "./Priority"
export { DueDate, type DueDate, createDueDate, today, tomorrow,
         daysFromNow, isOverdue, isToday, formatDueDate } from "./DueDate"

Интеграционный пример

import { Schema, Effect, pipe } from "effect"
import { TodoId, TodoTitle, Priority, DueDate, generateTodoId } from "./value-objects"

// Todo Entity использует все наши VO
class Todo extends Schema.Class<Todo>("Todo")({
  id: TodoId,
  title: TodoTitle,
  priority: Priority,
  dueDate: Schema.OptionFromNullOr(DueDate),
  completed: Schema.Boolean,
  createdAt: Schema.String.pipe(Schema.brand("CreatedAt"))
}) {}

// Фабрика создания Todo
const createTodo = (input: {
  readonly title: string
  readonly priority?: string
  readonly dueDate?: string | null
}) =>
  pipe(
    Effect.all({
      id: Effect.succeed(generateTodoId()),
      title: Schema.decodeUnknown(TodoTitle)(input.title),
      priority: Schema.decodeUnknown(Priority)(input.priority ?? "medium"),
      dueDate: input.dueDate
        ? Schema.decodeUnknown(DueDate)(input.dueDate).pipe(
            Effect.map((d) => d as DueDate | null)
          )
        : Effect.succeed(null),
      completed: Effect.succeed(false),
      createdAt: Effect.succeed(new Date().toISOString() as any)
    }),
    Effect.flatMap((fields) =>
      Schema.decodeUnknown(Todo)({
        ...fields,
        dueDate: fields.dueDate
      })
    )
  )

// Использование
const program = Effect.gen(function* () {
  const todo = yield* createTodo({
    title: "Купить продукты",
    priority: "high",
    dueDate: "2025-12-31"
  })

  console.log(`Todo: ${todo.title}`)
  console.log(`Priority: ${todo.priority}`)
  console.log(`Due: ${todo.dueDate}`)
})

Дополнительные упражнения для самостоятельной работы

Упражнение A: CompletionStatus

Создайте Value Object CompletionStatus используя Data.TaggedEnum:

  • Pending — задача не начата
  • InProgress — задача в работе (с полем startedAt: Date)
  • Completed — задача завершена (с полем completedAt: Date)
  • Cancelled — задача отменена (с полем reason: string)

Упражнение B: TodoTag

Создайте составной Value Object TodoTag через Schema.Class:

  • Поля: name (1-50 символов), color (hex-код цвета)
  • Автонормализация name в lowercase
  • Structural equality через Equal

Упражнение C: Percentage и Progress

Создайте Value Objects для отслеживания прогресса:

  • Percentage — число от 0 до 100 (branded number)
  • Progress — составной VO с current: number, total: number
  • Функция toPercentage(progress): Percentage
  • Функция isComplete(progress): boolean

Чеклист: правильный Value Object

Перед завершением убедитесь, что каждый ваш VO соответствует этому чеклисту:

  • Иммутабельность: все поля readonly
  • Валидация: невалидные данные не могут существовать
  • Нормализация: одинаковые значения имеют одинаковое представление
  • Branded type (для простых VO): нельзя перепутать с обычным примитивом
  • Equal (для составных VO): структурное сравнение
  • Smart constructor: Schema.decodeUnknown для безопасного создания
  • Чистые функции: операции не имеют побочных эффектов
  • Тесты: покрыты создание, валидация, операции, граничные случаи
  • Нулевые зависимости: VO не зависит от инфраструктуры
  • Документация: JSDoc с описанием и примерами