Пример: Email как Value Object с валидацией
Пошаговое построение production-ready Email VO. Нормализация (trim, lowercase), валидация RFC 5322, branded type. Операции: getLocalPart, getDomain, isCorporate, mask. Интеграция с доменной моделью, портами и HTTP-адаптером. Полный набор тестов.
Пример: Email как Value Object с валидацией
Введение
Email-адрес — это один из самых наглядных примеров Value Object. Это строка, но не любая строка — она должна соответствовать определённому формату, содержать ровно один символ @, иметь валидный домен и так далее. Если в вашей системе email хранится как обычный string, вы теряете все эти гарантии и вынуждены проверять формат каждый раз, когда используете email.
В этой статье мы пошагово построим полноценный Value Object Email на Effect-ts, начиная с наивной реализации и заканчивая production-ready решением. По пути мы разберём все тонкости: нормализацию, сравнение, работу с доменной частью и интеграцию с другими частями системы.
Почему Email — это Value Object
Email удовлетворяет всем критериям Value Object:
- Определяется значением: два email
user@example.com— это один и тот же email. - Неизменяем: email не может «измениться» — можно только заменить его новым.
- Самовалидируется: невалидный email не должен существовать в системе.
- Заменяем: любой email
user@example.comэквивалентен любому другомуuser@example.com.
// ❌ Без Value Object — email это просто string
interface User {
email: string // "", "invalid", "not@email", " spaces " — всё допустимо
}
// ✅ С Value Object — email гарантированно валиден
interface User {
email: Email // Только валидный, нормализованный email
}
Шаг 1: Наивная реализация
Начнём с простейшей реализации — branded string с регулярным выражением:
import { Schema } from "effect"
// Простейший Email VO — branded string с regex
const Email = Schema.String.pipe(
Schema.pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/),
Schema.brand("Email")
)
type Email = typeof Email.Type
Это работает, но имеет множество проблем:
" USER@EXAMPLE.COM "— с пробелами и в верхнем регистре не пройдёт."user@example.com"и"USER@EXAMPLE.COM"— будут разными значениями.- Нет нормализации.
- Нет доступа к частям email (local part, domain).
Шаг 2: Добавляем нормализацию
Email-адреса case-insensitive в доменной части и (по стандарту) case-sensitive в локальной части, но на практике почти все почтовые серверы обрабатывают локальную часть без учёта регистра. Для нашего домена мы будем нормализовать email в нижний регистр и убирать пробелы:
import { Schema } from "effect"
// Email с нормализацией через Schema.transform
const Email = Schema.transform(
// Входной тип: строка
Schema.String,
// Выходной тип: branded строка
Schema.String.pipe(
Schema.pattern(/^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/),
Schema.brand("Email")
),
{
// Encoding: email -> string (просто возвращаем как есть)
strict: true,
encode: (email) => email as string,
// Decoding: string -> email (нормализация + валидация)
decode: (input) => input.trim().toLowerCase()
}
)
type Email = typeof Email.Type
// Теперь работает нормализация:
const email1 = Schema.decodeUnknownSync(Email)(" USER@Example.COM ")
// email1 = "user@example.com" (нормализован)
Шаг 3: Production-ready реализация
Полноценная реализация Email VO с учётом всех требований:
import { Schema, Effect, Either, pipe, Brand } from "effect"
// ============================================================
// Email Value Object — Production-Ready Implementation
// ============================================================
// Регулярное выражение для валидации email
// Основано на RFC 5322, но упрощённо для практического использования
const EMAIL_REGEX = /^[a-z0-9._%+-]+@[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*\.[a-z]{2,}$/
// Максимальная длина email по RFC 5321
const MAX_EMAIL_LENGTH = 254
const MAX_LOCAL_PART_LENGTH = 64
// Schema для нормализованного email
const NormalizedEmailString = Schema.String.pipe(
Schema.maxLength(MAX_EMAIL_LENGTH, {
message: () => `Email must not exceed ${MAX_EMAIL_LENGTH} characters`
}),
Schema.pattern(EMAIL_REGEX, {
message: () => "Invalid email format"
}),
Schema.brand("Email")
)
/**
* Email Value Object
*
* Представляет валидный, нормализованный email-адрес.
* - Автоматическая нормализация: trim + lowercase
* - Валидация формата по RFC 5322 (упрощённая)
* - Ограничение длины: 254 символа (RFC 5321)
* - Branded type: нельзя перепутать с обычной строкой
*
* @example
* const email = Schema.decodeUnknownSync(Email)("User@Example.COM")
* // => "user@example.com" : Email
*/
export const Email = Schema.transform(
Schema.String.pipe(
Schema.annotations({
title: "Raw Email Input",
description: "Raw email string before normalization"
})
),
NormalizedEmailString,
{
strict: true,
decode: (raw) => {
const normalized = raw.trim().toLowerCase()
// Дополнительная проверка: нет двойных точек в локальной части
const localPart = normalized.split("@")[0] ?? ""
if (localPart.includes("..")) {
throw new Error("Email local part must not contain consecutive dots")
}
// Проверка длины локальной части
if (localPart.length > MAX_LOCAL_PART_LENGTH) {
throw new Error(
`Email local part must not exceed ${MAX_LOCAL_PART_LENGTH} characters`
)
}
return normalized
},
encode: (email) => email as string
}
).pipe(
Schema.annotations({
identifier: "Email",
title: "Email Address",
description: "A validated, normalized email address (RFC 5322 compliant)"
})
)
export type Email = typeof Email.Type
// ============================================================
// Smart Constructors
// ============================================================
/** Создание Email с валидацией — возвращает Effect */
export const createEmail = Schema.decodeUnknown(Email)
/** Создание Email с валидацией — синхронный, бросает при ошибке */
export const createEmailSync = Schema.decodeUnknownSync(Email)
/** Проверка Email — возвращает Either */
export const parseEmail = Schema.decodeUnknownEither(Email)
// ============================================================
// Операции над Email (чистые функции)
// ============================================================
/** Извлекает локальную часть email (до @) */
export const getLocalPart = (email: Email): string =>
(email as string).split("@")[0]!
/** Извлекает доменную часть email (после @) */
export const getDomain = (email: Email): string =>
(email as string).split("@")[1]!
/** Проверяет, принадлежит ли email указанному домену */
export const belongsToDomain = (email: Email, domain: string): boolean =>
getDomain(email) === domain.toLowerCase()
/** Проверяет, является ли email корпоративным (не бесплатным) */
export const isCorporate = (email: Email): boolean => {
const freeProviders = [
"gmail.com", "yahoo.com", "hotmail.com", "outlook.com",
"mail.ru", "yandex.ru", "protonmail.com", "icloud.com"
] as const
return !freeProviders.includes(getDomain(email) as any)
}
/** Маскирует email для отображения: u***r@example.com */
export const mask = (email: Email): string => {
const local = getLocalPart(email)
const domain = getDomain(email)
if (local.length <= 2) {
return `${local[0]}***@${domain}`
}
return `${local[0]}${"*".repeat(Math.min(local.length - 2, 3))}${local[local.length - 1]}@${domain}`
}
/** Генерирует Gravatar URL для email */
export const gravatarUrl = (email: Email, size: number = 80): string => {
// В реальном коде использовался бы crypto.subtle.digest для MD5
// Здесь упрощённая версия для демонстрации
const hash = Array.from(email as string)
.reduce((acc, char) => ((acc << 5) - acc + char.charCodeAt(0)) | 0, 0)
.toString(16)
.padStart(32, "0")
return `https://www.gravatar.com/avatar/${hash}?s=${size}&d=identicon`
}
Использование Email VO
В доменной модели
import { Schema } from "effect"
import { Email } from "./value-objects/Email"
// Email как поле Entity
class User extends Schema.Class<User>("User")({
id: Schema.String.pipe(Schema.brand("UserId")),
email: Email,
name: Schema.String.pipe(Schema.minLength(1)),
createdAt: Schema.DateFromSelf
}) {}
В порте (контракте)
import { Context, Effect } from "effect"
import { Email } from "./value-objects/Email"
// Порт нотификации — принимает Email, а не string
class NotificationPort extends Context.Tag("NotificationPort")<
NotificationPort,
{
readonly sendWelcome: (email: Email) => Effect.Effect<void, NotificationError>
readonly sendPasswordReset: (email: Email) => Effect.Effect<void, NotificationError>
}
>() {}
// Порт аутентификации
class AuthPort extends Context.Tag("AuthPort")<
AuthPort,
{
readonly findByEmail: (email: Email) => Effect.Effect<User, UserNotFound>
readonly registerByEmail: (email: Email, password: HashedPassword) => Effect.Effect<User>
}
>() {}
На границе HTTP-адаптера
import { Schema, Effect, pipe } from "effect"
import { Email, createEmail } from "./value-objects/Email"
// HTTP Request Schema
const CreateUserRequest = Schema.Struct({
email: Email, // Автоматическая нормализация и валидация при decode
name: Schema.String.pipe(Schema.minLength(1)),
password: Schema.String.pipe(Schema.minLength(8))
})
// В HTTP-адаптере
const handleCreateUser = (rawBody: unknown) =>
pipe(
// Шаг 1: Декодируем и валидируем входные данные
Schema.decodeUnknown(CreateUserRequest)(rawBody),
// Email уже нормализован и валиден на этом этапе!
Effect.flatMap(({ email, name, password }) =>
// Шаг 2: Передаём в application layer
createUserUseCase({ email, name, password })
),
// Шаг 3: Маппим ошибки валидации в HTTP 400
Effect.catchTag("ParseError", (err) =>
Effect.fail(new HttpError({ status: 400, message: err.message }))
)
)
В тестах
import { describe, it, expect } from "bun:test"
import { Schema, Either } from "effect"
import {
Email, createEmailSync, parseEmail,
getLocalPart, getDomain, belongsToDomain, isCorporate, mask
} from "./value-objects/Email"
describe("Email Value Object", () => {
// === Создание и валидация ===
describe("creation", () => {
it("creates from valid email", () => {
const email = createEmailSync("user@example.com")
expect(email).toBe("user@example.com")
})
it("normalizes to lowercase", () => {
const email = createEmailSync("USER@EXAMPLE.COM")
expect(email).toBe("user@example.com")
})
it("trims whitespace", () => {
const email = createEmailSync(" user@example.com ")
expect(email).toBe("user@example.com")
})
it("rejects empty string", () => {
const result = parseEmail("")
expect(Either.isLeft(result)).toBe(true)
})
it("rejects string without @", () => {
const result = parseEmail("userexample.com")
expect(Either.isLeft(result)).toBe(true)
})
it("rejects string with multiple @", () => {
const result = parseEmail("user@@example.com")
expect(Either.isLeft(result)).toBe(true)
})
it("rejects email without domain extension", () => {
const result = parseEmail("user@example")
expect(Either.isLeft(result)).toBe(true)
})
it("rejects email with consecutive dots", () => {
expect(() => createEmailSync("user..name@example.com")).toThrow()
})
it("accepts valid emails with subdomains", () => {
const email = createEmailSync("user@mail.example.co.uk")
expect(email).toBe("user@mail.example.co.uk")
})
it("accepts email with plus addressing", () => {
const email = createEmailSync("user+tag@example.com")
expect(email).toBe("user+tag@example.com")
})
it("accepts email with dots in local part", () => {
const email = createEmailSync("first.last@example.com")
expect(email).toBe("first.last@example.com")
})
})
// === Операции ===
describe("operations", () => {
const email = createEmailSync("john.doe@company.com")
it("extracts local part", () => {
expect(getLocalPart(email)).toBe("john.doe")
})
it("extracts domain", () => {
expect(getDomain(email)).toBe("company.com")
})
it("checks domain membership", () => {
expect(belongsToDomain(email, "company.com")).toBe(true)
expect(belongsToDomain(email, "other.com")).toBe(false)
})
it("identifies corporate email", () => {
expect(isCorporate(email)).toBe(true)
})
it("identifies free email", () => {
const freeEmail = createEmailSync("user@gmail.com")
expect(isCorporate(freeEmail)).toBe(false)
})
it("masks email for display", () => {
expect(mask(email)).toBe("j***e@company.com")
})
})
// === Равенство ===
describe("equality", () => {
it("equal after normalization", () => {
const email1 = createEmailSync("User@Example.COM")
const email2 = createEmailSync("user@example.com")
expect(email1).toBe(email2)
})
it("different emails are not equal", () => {
const email1 = createEmailSync("user1@example.com")
const email2 = createEmailSync("user2@example.com")
expect(email1).not.toBe(email2)
})
})
})
Расширение: Email с дополнительными свойствами
Вариант: Email как Schema.Class (составной VO)
Иногда вам нужно хранить не только нормализованную строку, но и предвычисленные свойства:
import { Schema } from "effect"
class EmailVO extends Schema.Class<EmailVO>("EmailVO")({
value: Schema.String.pipe(
Schema.pattern(/^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/)
),
localPart: Schema.String,
domain: Schema.String
}) {
static fromString(raw: string): EmailVO {
const normalized = raw.trim().toLowerCase()
const [localPart, domain] = normalized.split("@") as [string, string]
return new EmailVO({ value: normalized, localPart, domain })
}
}
// Использование
const email = EmailVO.fromString("User@Example.COM")
email.localPart // "user"
email.domain // "example.com"
email.value // "user@example.com"
Вариант: Типизированные домены
import { Schema } from "effect"
// Для систем, где важно различать типы email
const WorkEmail = Schema.String.pipe(
Schema.pattern(/^[a-z0-9._%+-]+@company\.com$/),
Schema.brand("WorkEmail")
)
type WorkEmail = typeof WorkEmail.Type
const PersonalEmail = Schema.String.pipe(
Schema.pattern(/^[a-z0-9._%+-]+@(gmail|yahoo|hotmail|outlook)\.[a-z]+$/),
Schema.brand("PersonalEmail")
)
type PersonalEmail = typeof PersonalEmail.Type
// Тип, принимающий любой из типов email
type AnyEmail = WorkEmail | PersonalEmail
// Функция, принимающая только рабочий email
const sendCorporateNotification = (email: WorkEmail): Effect.Effect<void> =>
// Гарантированно корпоративный email
Effect.void
Паттерн: Композиция Email с другими VO
Email часто используется вместе с другими Value Objects:
import { Schema, Data, Equal } from "effect"
// Контактная информация — составной VO из нескольких простых VO
class ContactInfo extends Schema.Class<ContactInfo>("ContactInfo")({
email: Email,
phone: Schema.OptionFromNullOr(
Schema.String.pipe(
Schema.pattern(/^\+\d{1,3}\d{4,14}$/),
Schema.brand("PhoneNumber")
)
),
preferredChannel: Schema.Literal("email", "phone", "both")
}) {}
// Подписка на рассылку
class NewsletterSubscription extends Schema.Class<NewsletterSubscription>(
"NewsletterSubscription"
)({
email: Email,
topics: Schema.Array(
Schema.Literal("tech", "business", "design", "ai")
),
subscribedAt: Schema.DateFromSelf
}) {}
Реальные edge cases
При работе с Email VO важно помнить о граничных случаях:
// Валидные (по RFC) но необычные email-адреса:
"user+tag@example.com" // Plus addressing
"user.name@example.com" // Dots in local part
"user@sub.domain.example.com" // Subdomains
"user@example.co.uk" // Multi-part TLD
"a@b.cc" // Минимальный email
"x".repeat(64) + "@b.cc" // 64 символа local part (максимум)
// Невалидные:
"" // Пустая строка
"user" // Нет @
"@example.com" // Нет local part
"user@" // Нет domain
"user@.com" // Домен начинается с точки
"user@com" // Нет TLD
"user @example.com" // Пробел в local part
"user@exam ple.com" // Пробел в domain
Резюме
Email как Value Object демонстрирует ключевые принципы:
- Нормализация при создании — trim + lowercase, чтобы одинаковые email всегда были равны.
- Валидация при создании — regex + бизнес-правила, чтобы невалидный email не существовал.
- Branded type — компилятор не позволит передать обычную строку вместо Email.
- Чистые функции — getLocalPart, getDomain, isCorporate — без побочных эффектов.
- Тестируемость — чистые значения тривиально тестируются.
Этот паттерн применим к любому Simple Value Object: URL, PhoneNumber, Slug, Username и так далее. В следующей статье мы разберём составной Value Object — Money.