Каждый раз, когда нужно добавить новую модель в проект, приходится писать буквально одинаковый код: с одинаковыми проверками, с одинаковыми корректировками, с одинаковыми Codable, с одинаковыми тестами.
Полагаю, вы тоже постоянно с этим сталкиваетесь, особенно при работе с текстом, например, вот типичный код:
struct User {
let name: String // Должно быть непустым и без пробелов
let awards: [Award] // Должны быть отсортированы по убыванию
let progress: Double // Не может быть отрицательным
init?(name: String, awards: [Award], progress: Double) {
let name = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty, progress >= 0 else { return nil }
self.name = name
self.awards = awards.sorted(by: >)
self.progress = progress
}
}
Всего три поля — и уже куча логики в инициализаторе. Причем пример-то еще упрощенный.
И не забываем добавить Codable, ведь данные приходят с сервера:
extension User: Codable {
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
guard let user = User(
name: try container.decode(String.self, forKey: .name),
awards: try container.decode([Award].self, forKey: .awards),
progress: try container.decode(Double.self, forKey: .progress)
) else {
throw DecodingError.dataCorrupted(.init(
codingPath: decoder.codingPath,
debugDescription: "Invalid Data"
))
}
self = user
}
}
Если этого не сделать, декодер пропустит ваш init? и запишет сырые данные прямо в поля. Валидации не будет.
Этот бойлерплейт приходится писать для каждой модели. И тестировать каждый инициализатор тоже нужно: пустая строка, отрицательное число, неотсортированный массив. И вот мы уже тестируем не бизнес-логику, а то, что guard не пропустит кривые данные.
Решение
Однажды я наткнулся на композицию объектов у Егора Бугаенко cactoos и, вдохновившись, попытался перенести все в Swift. Но в итоге пошел даже дальше: перенес валидацию из рантайма в типы.
Получилась библиотека Primity. Тот же User теперь выглядит так:
struct User: Codable {
let name: Name
let awards: Awards
let progress: Progress
}
extension User {
typealias Name = NonEmpty<Trimmed<String>>
typealias Awards = Descended<Array<Award>>
typealias Progress = NonNegative<Double>
}
Никаких guard, никакого ручного Codable — нет ничего лишнего и все понятно без комментариев.
Врапперы сериализуют свое содержимое напрямую. При декодировании сами вызывают свой init(_:) или init?(_:), и если данные не проходят валидацию — бросают ошибку. Вам не нужно писать init(from decoder:) руками.
Почему через typealias?
Я специально прячу все внутрь модели, чтобы не привязывать клиентский код к конкретной цепочке оберток. Клиенту не важно, что под капотом у User.Name. Если завтра я решу, что User.Name должен быть NonEmpty<Collapsed<Trimmed<String>>> вместо NonEmpty<Trimmed<String>>, все вызовы User.Name(expressing:) останутся прежними. Клиентский код не сломается, да и читается намного легче и понятнее.
// Хорошо: не важно, какие врапперы внутри
if let name = User.Name(expressing: input) { ... }
// Плохо: привязка к конкретной цепочке, сломается при изменениях
if let name = NonEmpty(Trimmed(input)) { ... }
Как это работает
Враппер — это структура с одним полем. Она дублирует поведение вложенного значения.
Врапперы бывают двух видов:
Валидаторы проверяют входное значение и отбрасывают плохое. Возвращают опционал:
- NonEmpty — непустая строка или коллекция
- NonNegative / Positive — числовые ограничения
- Within — значение в заданных границах
Корректоры всегда принимают значение и приводят его к нужному виду:
- Trimmed, Collapsed, Ragged, Stripped— чистят пробелы, декоративные символы
- Capitalized, Lowercased, Uppercased — меняют регистр
- Sorted — сортирует
- Truncated — обрезает по длине
- Clamped — ограничивает в заданном диапазоне
Обратите внимание на названия. Корректоры — это причастия с окончанием -ed: они описывают, что уже сделано со значением (обрезано, отсортировано, ограничено). Валидаторы — прилагательные: они описывают состояние значения (непустое, положительное, внутри диапазона). Такое разделение по частям речи помогает с первого взгляда отличить «всегда пропускает, но меняет» от «проверяет и либо принимает, либо отбрасывает».
Композиция вместо конфигурации
Сложные правила собираются из простых блоков:
typealias Tag = NonEmpty<Lowercased<Truncated<`32`,Collapsed<Trimmed<Stripped<String>>>>>>
let tag: Tag? = " Swift 💙 DEVelopment 💻 "
// "swift development"
Каждый слой делает одно дело. Результат — строка, которая гарантированно чистая, непустая и в нижнем регистре.
Вот еще пример, опции с вариантами ответов можно организовать так:
typealias TwoThroughNine<Value: Withinable> = Within<`2`, `9`, Value> where Value.Bound == Int
typealias AnswerOption = NonEmpty<Truncated<`32`,Collapsed<Trimmed<String>>>>
typealias AnswerOptions = TwoThroughNine<OrderedSet<AnswerOption>>
Или вот еще примеры:
typealias Rating = Clamped<`1`, `5`, Int>
typealias Percentage = Clamped<`0`, `100`, Int>
typealias Progress = Clamped<`0.0`, `1.0`, Double>
Работа с текстом
Вот строковые комбинации, которыми я постоянно пользуюсь, меняя только названия:
typealias Title = NonEmpty<Truncated<`256`,Collapsed<Trimmed<String>>>>
typealias Paragraph = NonEmpty<Truncated<`1024`,Collapsed<Trimmed<String>>>
typealias Name = NonEmpty<Truncated<`64`,Collapsed<Trimmed<Stripped<String>>>>>
typealias Tag = NonEmpty<Lowercased<Truncated<`32`,Collapsed<Trimmed<Stripped<String>>>>>>
let title: Title? = " Swift Development "
// "Swift Development"
let name: Name? = "👋 Mia 🌍"
// "Mia"
let tag: Tag? = " SWIFT DeV "
// "swift dev"
Порядок применения
Врапперы применяются справа налево:
typealias Tag = Lowercased<Truncated<`32`,Collapsed<Trimmed<Stripped<String>>>>>
// Эквивалентная цепочка
let tag = string
.stripped() // чистим от декоративных символов
.trimmed() // убираем пробелы по краям
.collapsed() // схлопываем множественные пробелы в один
.truncated(to: 32) // обрезаем по длине
.lowercased() // приводим к нижнему регистру
Порядок иногда важен. Если сначала обрезать по длине, а потом схлопнуть пробелы, результат получится короче, чем задумано.
Удобства
Врапперы понимают литералы
let string: Trimmed<Stripped<String>> = "hello world"
let array: NonEmpty<Ascended<Array<Int>>>? = [5, 1, 3, 2, 4]
let double: NonNegative<Double>? = 95.97
Все это построено на базовом протоколе Expressible с init(expressing:), который вызывает вложенные инициализаторы автоматически:
typealias Paragraph = NonEmpty<Collapsed<Trimmed<RichText>>>
let paragraph = Paragraph(expressing: richText)
// Без этого пришлось бы писать что-то такое
let paragraph = Paragraph(Collapsed(Trimmed(richText)))
Достать вложенное значение просто
Оператор * раскрывает любой враппер и возвращает его сырое значение. Без цепочки .value через слои:
let string = *Trimmed(Stripped("swift")) // String
let array = *NonEmpty(Ascended([5, 1, 3, 2, 4])) // [Int]?
let double = *NonNegative(95.97) // Double?
Если предпочитаете явные методы — они по-прежнему доступны:
let string = Trimmed(Stripped("swift")).asString()
let array = NonEmpty(Ascended([5, 1, 3, 2, 4]))?.asArray()
let double = NonNegative(95.97)?.asDouble()
// Базовый метод для всех типов
let text = Truncated(Collapsed(richText)).expressed()
Изменения без мутаций
Врапперы иммутабельны, но для базовых типов есть методы, которые возвращают новый враппер с измененными данными:
Ascended([5, 2, 1, 4, 3])
.appending(6)
NonEmpty(["en": "Hello", "fr": "Bonjour"])?
.setting("Привет", for: "ru")
Trimmed("Jobs")
.prepending("Steve ")
NonNegative(16.7)?
.multiplying(by: 9.4)
Методы есть для массивов, строк, словарей, сетов и чисел. Но вы также можете их добавить и для своих типов.
Проверка один раз
Главная сила — не только в том, что модель выглядит чище. Но и в том, что значение, однажды созданное враппером, можно спокойно передавать внутри системы.
Раньше метод получал значение и сразу требовал повторной проверки: пустая ли строка? Есть ли пробелы по краям? Отсортирован ли массив?
Сейчас метод принимает User.Name — и тип уже гарантирует корректность. Никаких guard внутри метода, никаких корректировок. Проверка произошла один раз, при создании, и больше не нужна нигде.
Такие гарантии сильно снижают кодовую базу. Валидация исчезает не только из моделей, но и из соответствующих методов, сервисов, контроллеров и всех тестов.
Рутинные тесты больше не нужны
Раньше каждый инициализатор требовал отдельных тестов: пустая строка, отрицательное число, неотсортированный массив. Мы тестировали не бизнес-логику, а то, что guard отработал правильно.
С врапперами эти проверки встроены в тип. NonEmpty не может содержать пустую строку, потому что компилятор не позволит создать такой экземпляр. NonNegative не может быть меньше нуля по определению типа.
Это как c Hashable — мы не пишем тесты, что словарь правильно хеширует ключи, потому что это гарантия языка. Валидация перешла из рантайма в типовую систему. Тестировать ее в своих моделях бессмысленно.
Тесты остаются только для бизнес-логики. А проверка «не пусто», «не отрицательно», «отсортировано» — это больше не ваша забота.
Codable из коробки
Враппер кодирует внутреннее значение напрямую — без оберток, без поля value. JSON остается плоским и обратно совместимым.
// Значение кодируется и декодируется напрямую
"swift dev"
// Без этого было бы вот так
{"value":{"value":{"value":{"value":{"value":"swift dev"}}}}}
То есть врапперы продолжают работать с теми же стерилизованными данными, что были до этого. Не нужно нигде ничего менять или подстраивать.
При декодировании враппер сам вызывает свой init(_:) или init?(_:). Если данные не проходят валидацию — враппер бросает понятную ошибку: «Value must not be empty», «Value '6' must be within bounds 1…5» и тому подобное.
Минусы тоже есть
Миграция существующего проекта требует времени. Нужно заменить типы в моделях, подружить код из разных частей системы, удалить ставшие лишними проверки и тесты. Да, новый функционал можно вводить поэтапно, но лучше всего сделать все разом — по модулю или по всему проекту.
На границах с другими библиотеками придется гонять типы туда-обратно: доставать значение из враппера для передачи во внешний API, оборачивать обратно на выходе. Это местами не так удобно, но терпимо — цена за гарантии внутри своего кода.
Но после перехода скорость добавления новых моделей и методов растет существенно. Меньше бойлерплейта, меньше тестов на валидацию, меньше мысленной нагрузки при чтении кода.
А что с производительностью?
Враппер — структура с одним полем. В Swift это value-type, она лежит на стеке или встроена в родителя без дополнительных аллокаций. NonEmpty<Trimmed<String>> занимает ровно столько же памяти, сколько String.
И еще, typealias — это псевдоним, а не новый тип. Если User.Name и Player.Name указывают на одну и ту же композицию, компилятор считает их одним типом и не дублирует метаданные.
На практике уникальных комбинаций мало: для строковых полей в проекте от силы три-четыре паттерна, и все модели их переиспользуют. Так что и тут оверхед копеечный.
Под капотом
Вся библиотека построена на Protocol-Oriented Programming и четырех базовых протоколах.
Все врапперы подчиняются протоколу AnyWrapping. От него разветвляются два:
- Wrapping — корректоры, инициализация всегда успешна
- MaybeWrapping — валидаторы, инициализация падает на плохих данных
То же самое с созданием из «сырого» значения: базовый AnyExpressible дает два пути:
- Expressible — для корректоров, создание всегда успешно
- MaybeExpressible — для валидаторов, создание может вернуть nil
Каждый враппер требует от вложенного значения ровно один протокол: Trimmed просит Trimmable, Capitalized — Capitalizable, Sorted — Sortable. Враппер сам ничего не делает — он лишь дублирует поведение вложенного значения.
В цепочке Capitalized<Trimmed<String>> внутренний String должен быть и Capitalizable, и Trimmable. Каждый слой добавляет одно ограничение, а компилятор собирает их вместе. NonEmpty<String> ведет себя как String, Ascended<Array<Int>> — как Array. Никакого сгенерированного кода, только протоколы и пустые расширения.
Если коротко, то каждый враппер создается по следующему шаблону:
protocol Capitalizable {
func capitalized() -> Self
}
struct Capitalized<Wrapped>: Wrapping where Wrapped: Capitalizable {
let value: Wrapped
init(_ value: Wrapped) {
self.value = value.capitalized()
}
}
Больше подробностей в README файле.
Философия дизайна
-
Один инвариант на тип — каждый примитив делает ровно одно дело
-
Ноль знаний о предметной области — никакой бизнес-логики, никаких внешних зависимостей
-
Композиция вместо конфигурации — собирайте типы в стек вместо передачи правил валидации
-
Ломаться сразу, а не потом — невалидные значения отклоняются в момент создания
Где найти
Primity выложен в открытый доступ. Внутри подробные комментарии в README и прямо в исходниках — загляните в любой файл, чтобы понять, как все устроено под капотом.
Если есть вопросы или предложения — пишите в комментариях или в issues, буду рад обсудить.
П.С. Я действительно вырезал тысячи строк валидации и тестов, так что заголовок не был кликбейтом. Из проекта на ~100 000 строк кода было вырезано ~5000: хелперы и их вызовы, проверки в инициализаторах и методах, ручные Codable-расширения, часть тестов для моделей и функций.
Модели теперь собираются просто и без какой-либо головной боли, а новый враппер, если он нужен, пишется в девять строк кода (ладно, с расширениями чуть больше). Код читается как обычный английский текст, поддерживается без усилий, и описывает сам себя.
Автор: goshatitov
