Type Safety и Parse Don't Validate
«Parse, don't validate» — Алексис Кинг
- Цели главы
- Parse, Don't Validate
- Контрактное программирование через типы
- Фантомные типы
- gleam_json — кодирование и декодирование
- gleam/dynamic/decode — полный обзор
- Railway-Oriented Programming — практика
- Проект: PokeAPI клиент
- Упражнения
- 1. PokemonId — opaque type + smart constructor (Лёгкое)
- 2. pokemon_decoder — расширенный декодер (Среднее)
- 3. pokemon_to_json — кодирование в JSON (Среднее)
- 4. search_results_decoder — пагинированный список (Среднее)
- 5. DamageMultiplier — фантомные типы (Среднее-Сложное)
- 6. format_pokemon_card — CLI-вывод (Среднее)
- 7. build_pokedex_entry — ROP-цепочка (Сложное)
- Заключение
Цели главы
В этой главе мы:
- Поймём паттерн «Parse, Don't Validate»
- Изучим непрозрачные типы (opaque types) и smart constructors
- Разберём фантомные типы (phantom types) для типобезопасных контрактов
- Освоим
gleam_jsonдля кодирования и декодирования JSON - Детально изучим
gleam/dynamic/decode - Применим Railway-Oriented Programming для композиции парсинга и валидации
- Построим PokeAPI-клиент, объединив все концепции
Parse, Don't Validate
«Parse, Don't Validate» — паттерн проектирования, предложенный Алексис Кинг. Суть: вместо проверки данных с последующим использованием «сырых» типов, парсите неструктурированные данные в структурированные типы.
Проблема: валидация
// ❌ Плохо: проверяем, но тип остаётся String
pub fn send_email(to: String) -> Result(Nil, String) {
case string.contains(to, "@") {
True -> do_send(to)
False -> Error("invalid email")
}
}
// Ничто не мешает вызвать do_send с невалидным email
fn do_send(to: String) -> Result(Nil, String) { ... }
Проблема валидации: даже если мы проверили строку, тип String не несёт никакой информации о том, прошла ли она проверку — любой код может обойти валидацию и передать произвольную строку напрямую.
Решение: парсинг
// ✓ Хорошо: парсим String → Email, дальше работаем с Email
pub opaque type Email {
Email(String)
}
pub fn parse_email(s: String) -> Result(Email, String) {
case string.contains(s, "@") {
True -> Ok(Email(s))
False -> Error("некорректный email")
}
}
pub fn send_email(to: Email) -> Result(Nil, String) {
// Гарантированно валидный email!
do_send(email_to_string(to))
}
Разница: send_email принимает Email, а не String. Единственный способ получить Email — через parse_email, которая проверяет формат. Тип гарантирует валидность.
Контрактное программирование через типы
Непрозрачные типы (opaque types) реализуют контракты — предусловия, которые невозможно нарушить:
Непрозрачные типы (opaque types)
opaque type скрывает конструктор от внешнего кода. Значение можно создать только через функции модуля:
// В модуле positive.gleam
pub opaque type Positive {
Positive(Int)
}
pub fn new(n: Int) -> Result(Positive, String) {
case n > 0 {
True -> Ok(Positive(n))
False -> Error("число должно быть положительным")
}
}
pub fn value(p: Positive) -> Int {
let Positive(n) = p
n
}
pub fn add(a: Positive, b: Positive) -> Positive {
// Безопасно: сумма двух положительных всегда положительна
Positive(value(a) + value(b))
}
Внешний код не может создать Positive(-5) — только через new, который проверяет инвариант. Все функции внутри модуля могут доверять, что Positive содержит положительное число.
Smart constructors
Smart constructor — функция, создающая значение с проверкой:
pub opaque type Age {
Age(Int)
}
pub fn age(n: Int) -> Result(Age, String) {
case n {
_ if n < 0 -> Error("возраст не может быть отрицательным")
_ if n > 150 -> Error("возраст слишком большой")
_ -> Ok(Age(n))
}
}
pub fn age_value(a: Age) -> Int {
let Age(n) = a
n
}
Паттерн:
opaque typeскрывает конструктор- Smart constructor проверяет инварианты
- Аксессоры дают доступ к данным
- Все функции модуля доверяют инварианту
Когда использовать opaque types?
- Email, URL, телефонный номер — форматированные строки
- Положительные числа, проценты, возраст — числа с ограничениями
- Идентификаторы — UUID, OrderId, UserId
- Токены — JWT, API-ключи
Фантомные типы
Фантомный тип (phantom type) — параметр типа, который не используется в данных, но проверяется компилятором:
pub opaque type Currency(unit) {
Currency(amount: Int)
}
// Типы-метки (пустые, без значений)
pub type USD
pub type EUR
pub fn usd(amount: Int) -> Currency(USD) {
Currency(amount)
}
pub fn eur(amount: Int) -> Currency(EUR) {
Currency(amount)
}
pub fn add(a: Currency(unit), b: Currency(unit)) -> Currency(unit) {
Currency(a.amount + b.amount)
}
Обратите внимание: Currency(unit) содержит только Int, но параметр unit отличает доллары от евро на уровне типов:
let price = usd(100)
let tax = usd(20)
let total = add(price, tax) // ✓ Ok: Currency(USD) + Currency(USD)
let euros = eur(50)
// add(price, euros) // ✗ Ошибка компиляции!
// Expected Currency(USD), got Currency(EUR)
Компилятор не даст сложить доллары с евро — ошибка обнаруживается до запуска программы.
Ещё примеры фантомных типов
// Статус: проверенный vs непроверенный
pub type Verified
pub type Unverified
pub opaque type Document(status) {
Document(content: String)
}
pub fn create(content: String) -> Document(Unverified) {
Document(content:)
}
pub fn verify(doc: Document(Unverified)) -> Result(Document(Verified), String) {
// ... проверка ...
Ok(Document(content: doc.content))
}
pub fn publish(doc: Document(Verified)) -> Nil {
// Можно публиковать только проверенные документы!
...
}
Фантомный тип Document(status) кодирует состояние документа прямо в сигнатуре типа: publish принимает только Document(Verified), и компилятор не позволит передать непроверенный документ без явного вызова verify.
gleam_json — кодирование и декодирование
Библиотека gleam_json предоставляет функции для работы с JSON.
Кодирование (JSON → строка)
import gleam/json
// Примитивы
json.string("hello") // "hello"
json.int(42) // 42
json.float(3.14) // 3.14
json.bool(True) // true
json.null() // null
// Nullable — Some → значение, None → null
json.nullable(Some("hi"), json.string) // "hi"
json.nullable(None, json.string) // null
// Массивы
json.array([1, 2, 3], json.int) // [1, 2, 3]
json.preprocessed_array([json.int(1), json.string("hi")])
// [1, "hi"]
// Объекты
json.object([
#("name", json.string("Алиса")),
#("age", json.int(30)),
#("active", json.bool(True)),
])
// {"name":"Алиса","age":30,"active":true}
// Преобразование в строку
json.object([#("x", json.int(1))])
|> json.to_string
// "{\"x\":1}"
// С форматированием
json.object([#("x", json.int(1))])
|> json.to_string_tree
|> string_tree.to_string
Модуль gleam/json предоставляет строительные блоки для всех JSON-типов: примитивы конструируются отдельными функциями, объекты и массивы — через json.object и json.array, а итоговая строка получается вызовом json.to_string.
Кодирование пользовательских типов
pub type User {
User(name: String, age: Int, email: String)
}
pub fn user_to_json(user: User) -> json.Json {
json.object([
#("name", json.string(user.name)),
#("age", json.int(user.age)),
#("email", json.string(user.email)),
])
}
// Использование
User("Алиса", 30, "alice@example.com")
|> user_to_json
|> json.to_string
// {"name":"Алиса","age":30,"email":"alice@example.com"}
Для каждого пользовательского типа создаётся функция-кодировщик, которая отображает поля Gleam-структуры в пары ключ-значение JSON — такой подход легко масштабируется на вложенные типы.
Декодирование
Декодирование — преобразование JSON-строки обратно в Gleam-типы. Это двухшаговый процесс:
- Парсинг: строка → Dynamic (нетипизированные данные)
- Декодирование: Dynamic → типизированное значение
import gleam/dynamic/decode
import gleam/json
// Декодер для User
pub fn user_decoder() -> decode.Decoder(User) {
use name <- decode.field("name", decode.string)
use age <- decode.field("age", decode.int)
use email <- decode.field("email", decode.string)
decode.success(User(name:, age:, email:))
}
// Парсинг JSON-строки
json.parse("{\"name\":\"Алиса\",\"age\":30,\"email\":\"a@b.com\"}", user_decoder())
// Ok(User("Алиса", 30, "a@b.com"))
json.parse("{\"name\":\"Алиса\"}", user_decoder())
// Error(...) — не хватает полей
json.parse объединяет парсинг строки и применение декодера в один шаг: если JSON некорректен или не соответствует структуре декодера, возвращается Error с описанием проблемы.
gleam/dynamic/decode — полный обзор
Модуль gleam/dynamic/decode предоставляет типобезопасные декодеры для работы с нетипизированными данными.
Примитивные декодеры
import gleam/dynamic/decode
decode.string // Decoder(String)
decode.int // Decoder(Int)
decode.float // Decoder(Float)
decode.bool // Decoder(Bool)
decode.bit_array // Decoder(BitArray)
decode.dynamic // Decoder(Dynamic) — пропускает как есть
Примитивные декодеры — это готовые значения типа Decoder(T), которые извлекают соответствующий тип из динамических данных и возвращают ошибку, если тип не совпадает.
Поля объектов
// field — обязательное поле
use name <- decode.field("name", decode.string)
// optional_field — может отсутствовать
use nickname <- decode.optional_field("nickname", None, decode.string)
// subfield — вложенное поле (путь)
use city <- decode.subfield(["address", "city"], decode.string)
// at — по индексу в массиве
use first <- decode.at([0], decode.string)
decode.field извлекает обязательное поле объекта, decode.optional_field возвращает None если поле отсутствует, а decode.subfield позволяет обращаться к глубоко вложенным полям по пути из ключей.
Коллекции
// list — массив
decode.list(decode.int) // Decoder(List(Int))
// dict — объект как словарь
decode.dict(decode.string, decode.int) // Decoder(Dict(String, Int))
// optional — nullable значение
decode.optional(decode.string) // Decoder(Option(String))
Декодеры коллекций оборачивают декодер элемента: decode.list применяет его к каждому элементу массива, decode.dict — к ключам и значениям объекта, decode.optional обрабатывает null как None.
Комбинаторы
// one_of — попробовать несколько декодеров
decode.one_of(decode.string, [
decode.int |> decode.map(int.to_string),
])
// Попробует string, если не получится — int → string
// then — зависимый декодер
use type_name <- decode.field("type", decode.string)
decode.then(fn() {
case type_name {
"circle" -> circle_decoder()
"rect" -> rect_decoder()
_ -> decode.failure(UnknownShape, "Shape")
}
})
// success — декодер, который всегда успешен
decode.success(42) // Decoder(Int) — всегда вернёт 42
// failure — декодер, который всегда неуспешен
decode.failure(MyError, "expected something else")
Комбинаторы позволяют строить гибкие декодеры: decode.one_of перебирает альтернативы по порядку, decode.then создаёт зависимые декодеры на основе уже декодированного значения, а decode.map трансформирует результат без изменения структуры.
Пример: вложенная структура
pub type Address {
Address(city: String, street: String)
}
pub type Person {
Person(name: String, age: Int, address: Address)
}
pub fn address_decoder() -> decode.Decoder(Address) {
use city <- decode.field("city", decode.string)
use street <- decode.field("street", decode.string)
decode.success(Address(city:, street:))
}
pub fn person_decoder() -> decode.Decoder(Person) {
use name <- decode.field("name", decode.string)
use age <- decode.field("age", decode.int)
use address <- decode.field("address", address_decoder())
decode.success(Person(name:, age:, address:))
}
// Парсинг
let json_str = "{
\"name\": \"Алиса\",
\"age\": 30,
\"address\": {\"city\": \"Москва\", \"street\": \"Тверская\"}
}"
json.parse(json_str, person_decoder())
// Ok(Person("Алиса", 30, Address("Москва", "Тверская")))
Вложенные структуры декодируются путём передачи одного декодера в качестве аргумента другому: decode.field("address", address_decoder()) автоматически применит address_decoder к значению поля "address".
Рекурсивные декодеры
Для рекурсивных структур используйте decode.recursive:
pub type Tree {
Leaf(value: Int)
Node(left: Tree, right: Tree)
}
pub fn tree_decoder() -> decode.Decoder(Tree) {
decode.one_of(leaf_decoder(), [node_decoder()])
}
fn leaf_decoder() -> decode.Decoder(Tree) {
use value <- decode.field("value", decode.int)
decode.success(Leaf(value:))
}
fn node_decoder() -> decode.Decoder(Tree) {
use left <- decode.field("left", decode.recursive(tree_decoder))
use right <- decode.field("right", decode.recursive(tree_decoder))
decode.success(Node(left:, right:))
}
decode.recursive нужен, чтобы избежать бесконечной рекурсии при создании декодера — он откладывает вычисление до момента использования.
Railway-Oriented Programming — практика
Объединим всё в ROP-цепочку: сырые данные → парсинг → декодирование → валидация → доменный тип:
import gleam/dynamic/decode
import gleam/int
import gleam/json
import gleam/result
import gleam/string
pub opaque type ValidUser {
ValidUser(name: String, age: Int, email: String)
}
pub fn valid_user_name(u: ValidUser) -> String { u.name }
pub fn valid_user_age(u: ValidUser) -> Int { u.age }
pub fn valid_user_email(u: ValidUser) -> String { u.email }
type RawUser {
RawUser(name: String, age: Int, email: String)
}
fn raw_user_decoder() -> decode.Decoder(RawUser) {
use name <- decode.field("name", decode.string)
use age <- decode.field("age", decode.int)
use email <- decode.field("email", decode.string)
decode.success(RawUser(name:, age:, email:))
}
pub fn parse_valid_user(json_str: String) -> Result(ValidUser, String) {
// 1. Парсинг JSON
use raw <- result.try(
json.parse(json_str, raw_user_decoder())
|> result.map_error(fn(_) { "некорректный JSON" }),
)
// 2. Валидация имени
use _ <- result.try(case string.length(raw.name) >= 2 {
True -> Ok(Nil)
False -> Error("имя слишком короткое")
})
// 3. Валидация email
use _ <- result.try(case string.contains(raw.email, "@") {
True -> Ok(Nil)
False -> Error("некорректный email")
})
// 4. Валидация возраста
use _ <- result.try(case raw.age >= 0 && raw.age <= 150 {
True -> Ok(Nil)
False -> Error("некорректный возраст")
})
// 5. Создание доменного типа
Ok(ValidUser(name: raw.name, age: raw.age, email: raw.email))
}
Каждый шаг может вернуть ошибку, и цепочка result.try прервётся на первом Error. Результат — либо полностью валидный ValidUser, либо описание ошибки.
Проект: PokeAPI клиент
Объединим все концепции в практическом проекте — клиенте для PokeAPI. API возвращает JSON с информацией о покемонах.
Доменная модель
pub type Pokemon {
Pokemon(
id: Int,
name: String,
height: Int,
weight: Int,
types: List(String),
)
}
Доменная модель описывает только те поля PokeAPI-ответа, которые нужны приложению — id, название, размеры и список типов — игнорируя всё остальное.
Декодер
PokeAPI возвращает вложенную структуру. Типы покемона находятся в types[].type.name:
{
"id": 25,
"name": "pikachu",
"height": 4,
"weight": 60,
"types": [
{"slot": 1, "type": {"name": "electric", "url": "..."}}
]
}
Декодер:
import gleam/dynamic/decode
pub fn pokemon_decoder() -> decode.Decoder(Pokemon) {
use id <- decode.field("id", decode.int)
use name <- decode.field("name", decode.string)
use height <- decode.field("height", decode.int)
use weight <- decode.field("weight", decode.int)
use types <- decode.field("types", decode.list(type_name_decoder()))
decode.success(Pokemon(id:, name:, height:, weight:, types:))
}
fn type_name_decoder() -> decode.Decoder(String) {
use name <- decode.subfield(["type", "name"], decode.string)
decode.success(name)
}
pokemon_decoder компонует несколько decode.field через use-синтаксис, а вспомогательный type_name_decoder использует decode.subfield для навигации по вложенной структуре types[].type.name.
HTTP-запрос (для gleam run, не для тестов)
В реальном приложении запрос делается через gleam_httpc:
import gleam/http/request
import gleam/httpc
import gleam/json
import gleam/result
pub fn fetch_pokemon(name: String) -> Result(Pokemon, String) {
let url = "https://pokeapi.co/api/v2/pokemon/" <> name
use req <- result.try(
request.to(url)
|> result.map_error(fn(_) { "некорректный URL" }),
)
use resp <- result.try(
httpc.send(req)
|> result.map_error(fn(_) { "ошибка HTTP-запроса" }),
)
use pokemon <- result.try(
json.parse(resp.body, pokemon_decoder())
|> result.map_error(fn(_) { "ошибка декодирования JSON" }),
)
Ok(pokemon)
}
Примечание: в тестах мы не делаем реальных HTTP-запросов. Вместо этого используем захардкоженный JSON-ответ. Это стандартная практика — тесты должны быть быстрыми и детерминированными.
Парсинг без сети (для тестов)
pub fn parse_pokemon(json_str: String) -> Result(Pokemon, Nil) {
json.parse(json_str, pokemon_decoder())
|> result.map_error(fn(_) { Nil })
}
parse_pokemon — упрощённая версия без HTTP-запроса: она принимает уже готовую JSON-строку и применяет декодер, что делает функцию удобной для тестирования с захардкоженными данными.
Упражнения
Все упражнения этой главы складываются в мини-проект PokeDex CLI — консольное приложение для работы с данными о покемонах. Каждое упражнение — это строительный блок реального приложения.
Решения пишите в файле exercises/chapter07/test/my_solutions.gleam. Запускайте тесты:
cd exercises/chapter07
gleam test
Запускайте тесты после каждого упражнения — они проверяют корректность реализации и подскажут, что ещё нужно доделать.
1. PokemonId — opaque type + smart constructor (Лёгкое)
Создайте непрозрачный тип PokemonId для валидного ID покемона (диапазон 1–1025, как в реальной PokéAPI).
pub opaque type PokemonId {
PokemonId(Int)
}
pub fn pokemon_id_new(id: Int) -> Result(PokemonId, String)
pub fn pokemon_id_value(id: PokemonId) -> Int
pub fn pokemon_id_to_path(id: PokemonId) -> String
Примеры:
pokemon_id_new(25) |> result.is_ok == True
pokemon_id_new(0) |> result.is_error == True
pokemon_id_new(1026) |> result.is_error == True
pokemon_id_to_path(pokemon_id_new(25) |> result.unwrap(..))
== "/api/v2/pokemon/25"
Подсказка: pokemon_id_to_path формирует строку "/api/v2/pokemon/" <> int.to_string(id).
2. pokemon_decoder — расширенный декодер (Среднее)
Расширьте тип Pokemon полями abilities и stats, затем реализуйте декодер для реального формата PokeAPI.
pub type PokemonStat {
PokemonStat(name: String, base_value: Int)
}
pub type Pokemon {
Pokemon(
id: Int, name: String, height: Int, weight: Int,
types: List(String),
abilities: List(String),
stats: List(PokemonStat),
)
}
pub fn pokemon_decoder() -> decode.Decoder(Pokemon)
pub fn parse_pokemon(json_str: String) -> Result(Pokemon, Nil)
Структура JSON PokeAPI:
{
"types": [{"slot": 1, "type": {"name": "electric", "url": "..."}}],
"abilities": [{"ability": {"name": "static", "url": ""}, "is_hidden": false, "slot": 1}],
"stats": [{"base_stat": 35, "effort": 0, "stat": {"name": "hp", "url": ""}}]
}
Подсказка: создайте три вспомогательных декодера — type_name_decoder (через decode.subfield(["type", "name"], ...)), ability_name_decoder (через decode.subfield(["ability", "name"], ...)), stat_decoder (поле "base_stat" + subfield ["stat", "name"]).
3. pokemon_to_json — кодирование в JSON (Среднее)
Закодируйте Pokemon в компактный JSON-формат (для кеширования, не в формате PokeAPI).
pub fn stat_to_json(stat: PokemonStat) -> json.Json
pub fn pokemon_to_json(pokemon: Pokemon) -> json.Json
Формат: плоский объект с полями id, name, height, weight, types (массив строк), abilities (массив строк), stats (массив объектов {name, base_value}).
Подсказка: используйте json.object, json.string, json.int, json.array.
4. search_results_decoder — пагинированный список (Среднее)
Декодируйте ответ PokeAPI для списка покемонов с пагинацией.
pub type NamedResource {
NamedResource(name: String, url: String)
}
pub type SearchResults {
SearchResults(
count: Int,
next: Option(String),
previous: Option(String),
results: List(NamedResource),
)
}
pub fn decode_search_results(json_str: String) -> Result(SearchResults, Nil)
Поля next и previous — nullable (null на первой/последней странице).
Подсказка: используйте decode.optional(decode.string) для nullable-полей. Это не decode.optional_field — поле всегда присутствует, но его значение может быть null.
5. DamageMultiplier — фантомные типы (Среднее-Сложное)
Реализуйте типобезопасный множитель урона с фантомными типами Physical / Special. Компилятор не позволит перемножить физический и специальный множители.
pub type Physical
pub type Special
pub opaque type DamageMultiplier(category) {
DamageMultiplier(Float)
}
pub fn physical(value: Float) -> Result(DamageMultiplier(Physical), String)
pub fn special(value: Float) -> Result(DamageMultiplier(Special), String)
pub fn multiplier_value(m: DamageMultiplier(a)) -> Float
pub fn combine(a: DamageMultiplier(cat), b: DamageMultiplier(cat)) -> DamageMultiplier(cat)
pub fn apply_damage(base: Int, m: DamageMultiplier(a)) -> Int
Примеры:
physical(1.5) |> result.unwrap(..) |> multiplier_value == 1.5
physical(-0.5) |> result.is_error == True
// combine умножает два множителя:
combine(physical(1.5), physical(2.0)) |> multiplier_value == 3.0
// apply_damage: base * multiplier, округлено вниз
apply_damage(100, physical(1.5)) == 150
// combine(physical(1.5), special(2.0)) — ошибка компиляции!
Фантомный тип не позволяет перемножить физический и специальный множители, а combine соединяет два множителя одной категории, перемножая их значения.
6. format_pokemon_card — CLI-вывод (Среднее)
Отформатируйте покемона для красивого вывода в CLI.
pub fn format_pokemon_card(pokemon: Pokemon) -> String
pub fn format_stat_bar(stat: PokemonStat) -> String
format_pokemon_card возвращает:
#025 Pikachu
Тип: electric
Способности: static, lightning-rod
- ID дополняется нулями до 3 цифр (
#006,#025), но 4-значные не обрезаются (#1025) - Имя покемона с заглавной буквы (
pikachu→Pikachu)
format_stat_bar возвращает:
hp [##.............] 35
- Имя стата — 16 символов (дополнено пробелами справа)
- Бар — 15 символов:
#— заполненные,.— пустые - Масштаб: 0–255 → 0–15 символов (используйте
float.round) - Значение в конце
Подсказка: для zero-padding используйте string.repeat("0", pad) <> int.to_string(id).
7. build_pokedex_entry — ROP-цепочка (Сложное)
Постройте полный конвейер: JSON-строка → покемон → валидация → отформатированная карточка.
pub type PokedexError {
InvalidJson
InvalidPokemonId
MissingTypes
MissingAbilities
}
pub fn build_pokedex_entry(json_str: String) -> Result(String, PokedexError)
Цепочка (используйте result.try):
- Распарсить JSON в
Pokemon→ ошибкаInvalidJson - Проверить
idв диапазоне 1–1025 → ошибкаInvalidPokemonId - Проверить
typesне пуст → ошибкаMissingTypes - Проверить
abilitiesне пуст → ошибкаMissingAbilities - Отформатировать через
format_pokemon_card
Примеры:
build_pokedex_entry(pikachu_json) |> result.is_ok == True
build_pokedex_entry("not json") == Error(InvalidJson)
build_pokedex_entry(pokemon_with_id_99999) == Error(InvalidPokemonId)
Подсказка: каждый шаг — use _ <- result.try(...), как в примере с parse_valid_user.
Заключение
В этой главе мы изучили фундаментальные принципы типобезопасного программирования в Gleam:
- Parse, Don't Validate — превращение проверок в гарантии типов
- Opaque types и smart constructors — сокрытие реализации и поддержание инвариантов
- Phantom types — безопасность на уровне типов без накладных расходов
- gleam_json и gleam/dynamic/decode — типобезопасная работа с JSON
- Railway-Oriented Programming — композиция парсинга, декодирования и валидации
Эти паттерны — основа надёжных систем. Компилятор становится союзником: он не даст передать невалидный email, сложить доллары с евро или опубликовать непроверенный документ.
В следующих главах мы изучим, как взаимодействовать с Erlang и JavaScript платформами через FFI, расширяя возможности Gleam за счёт богатых экосистем обеих платформ.