Упражнения: 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: 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 с описанием и примерами