При моделировании сущностей с помощью TypeScript очень часто получается интерфейс следующего вида:
interface User {
id: number
username: string
}
interface Order {
id: number
userId: number
title: string
year: number
month: number
day: number
amount: { currency: 'EUR' | 'USD', value: number }
}
Проблема
Типы свойств не имеют семантического значения. С точки зрения типов User.id
, Order.id
, Order.year
и т. д. одинаковы: число, и как число они взаимозаменяемы, но семантически они различаются.
Исходя из предыдущего примера, мы можем иметь набор функций, которые выполняют действия над сущностями. Например:
function getOrdersFiltered(userId: number, year: number, month: number, day: number, amount: number) { // ...}
function deleteOrder(id: number) { // ... }
Эти функции будут принимать любое число в любом аргументе, независимо от семантического значения числа. Например:
const id = getUserId()
deleteOrder(id)
Очевидно, что это большая ошибка, и кажется, что можно избежать ее, прочитав код, но код не всегда так прост, как в примере.
То же самое происходит с getOrdersFiltered
: мы можем поменять значения дня и месяца, и не получим никакого предупреждения или ошибки. Ошибки возникнут, если день больше 12, но очевидно, что результат не будет ожидаемым.
Решение
Правила объектной калистеники предоставляют решение для этого: обернуть все примитивы и строки (связанный с антипаттерном Primitive obsession). Правило заключается в обертывании примитивов объектом, который представляет семантическое значение (DDD описывает это как ValueObjects
).
Но с TypeScript нам не нужно использовать классы или объекты для этого: мы можем использовать систему типов, чтобы гарантировать, что число, представляющее что-то отличное от года, не может быть использовано вместо года.
Маркированные типы
Этот шаблон использует расширяемость типов для добавления свойства, которое обеспечивает семантическое значение:
type Year = number & { __brand: 'year' }
Эта простая строка создаёт новый тип, который может работать как число — но не является числом, это год.
const year = 2012 as Year
function age(year: Year): number { //... }
age(2012) // ❌ IDE will show an error as 2012 is not a Year
age(year) // ✅
Обобщение решения
Чтобы избежать написания типа для каждого брендированного типа, мы можем создать утилитарный тип, например:
declare const __brand: unique symbol
export type Branded<T, B> = T & { [__brand]: B }
Который использует уникальный символ в качестве имени свойства бренда, чтобы избежать конфликтов с вашими свойствами, и получает оригинальный тип и бренд в качестве обобщённых параметров.
С этим мы можем рефакторить наши модели и функции следующим образом:
type UserId = Branded<number, 'UserId'>
type OrderId = Branded<number, 'OrderId'>
type Year = Branded<number, 'Year'>
type Month = Branded<number, 'Month'>
type Day = Branded<number, 'Day'>
type Amount = Branded<{ currency: 'EUR' | 'USD', value: number}, 'Amount'>
interface User {
id: UserId
username: string
}
interface Order {
id: OrderId
userId: UserId
title: string
year: Year
month: Month
day: Day
amount: Amount
}
function getOrdersFiltered(userId: UserId, year: Year, month: Month, day: Day, amount: Amount) { // ...}
function deleteOrder(id: OrderId) { // ... }
Теперь, в этом примере, IDE покажет ошибку, так как id
— это UserId
, а deleteOrder
ожидает OrderId
.
const id = getUserId()
deleteOrder(id) // ❌ IDE will show an error as id is UserID and deleteOrder expects OrderId
Компромиссы
В качестве небольшого компромисса вам нужно будет использовать X
как Brand
. Например, const year = 2012 as Year
при создании нового значения из примитива, но это эквивалентно new Year(2012)
, если вы используете объект значения. Вы можете предоставить функцию, которая будет работать как своего рода “конструктор”:
function year(year: number): Year {
return year as Year
}
Валидация с помощью брендированных типов
Брендированные типы также полезны для обеспечения валидности данных, так как вы можете иметь специфические типы для валидированных данных, и вы можете доверять, что пользователь был валидирован, просто используя типы:
type User = { id: UserId, email: Email}
type ValidUser = Readonly<Brand<User, 'ValidUser'>>
function validateUser(user: User): ValidUser {
// Checks if user is in the database
if (!/* logic to check the user is in database */) {
throw new InvalidUser()
}
return user as ValidUser
}
// We can not pass just a User, needs to be a ValidUser
function doSomethingWithAValidUser(user: ValidUser) {
}
Readonly
не обязательна, но чтобы убедиться, что ваш код не изменит данные после их валидации, это очень рекомендуется.
Резюме
Брендированные типы — это простое решение, которое включает следующее:
- Улучшает читаемость кода: Делает яснее, какое значение должно быть использовано в каждом аргументе
- Надежность: Помогает избежать ошибок в коде, которые могут быть трудными для обнаружения; теперь IDE (и проверка типов) помогают нам выявить, находится ли значение в правильном месте
- Валидация данных: Вы можете использовать брендированные типы, чтобы гарантировать, что данные действительны.
Вы можете рассматривать брендированные типы как своего рода версию ValueObjects
, но без использования классов — только типы и функции.
Наслаждайтесь мощью типизации!
Source:
https://dzone.com/articles/branded-types-in-typescript