Допустим мы разрабатываем простейшее приложение – менеджер задач. У нас будет всего две сущности: проект и задача. В качестве идентификаторов объектов будем использовать int
(ведь обычно мы сохраняем в реляционную базу, то почему бы и нет?). Задачу можно назначить на проект и для этого у нас есть простой интерфейс:
function assignTaskToProject(taskId: number, projectId: number) {}
И где-то в обработчике событий UI подальше от описанного выше интерфейса мы делаем следующее:
const projectId = project.id
const taskId = tasks.getUnassigned().first().id
assignTaskToProject(projectId, taskId)
Без подглядывания в описание интерфейса – какие тут проблемы?
- Во-первых, аргументы перепутаны местами и код не будет работать так как от него ожидают.
- Во-вторых, этот код скомпилируется и мы узнаем об ошибке только в runtime. Хорошо если не на production.
- В-третьих, (в особо запущенных случаях, и я уверен у вас были такие случаи) отладка такого кода может занять достаточно времени.
Чего бы хотелось? Чтобы компилятор проверял такого рода ошибки и запрещал передавать/назначать идентификатор одного типа (для проектов, например) как идентификатор другого типа (задач, например).
Наивное решение
Для начала создадим простой ValueObject для хранения Id.
class Identity<TType> {
constructor(private readonly value: TType) { }
equals(other: Identity<TType>): boolean {
return this.value === other.value
}
}
Теперь реализуем несколько идентификаторы, для наших сущностей и обновим интерфейс, чтобы он принимал идентификаторы только определённого типа.
class TaskId extends Identity<number> { }
class ProjectId extends Identity<number> { }
// обновим интерфейс
function assignTaskToProject(taskId: TaskId, projectId: ProjectId) {}
Теперь попробуем сделать что-нибудь плохое и передать неправильный идентификатор. И да это сработает, компилятор не будет ругаться.
assignTaskToProject(new TaskId(42), new TaskId(42)) // it works O_O
// И даже так можно
const taskId = new TaskId(42) as ProjectId
const wtfId = new Identity<number>(42) as ProjectId as TaskId
Почему это работает? Чтобы это понять, рассмотрим разные системы типов: номинальную и структурную. Номинальная система типов использует имя типа для проверки эквивалентности. Структураная же система прверяет структуру типа: имя свойств и их тип. Для номинальной системы типов существует разница между TypeId
и TaskId
, потому что они имеют разные имена. Но между этими типами нет никакой разницы для структурной системы типов (и TypeSrcipt использует её), потому что они имеют одни и те же свойства тех же типов. Итак, чтобы сделать эти типы отличными друг от друга, мы должны изменить их структуру.
Брендирование типов
Немного модифицируем наш класс, добавив “брендирование”:
class Identity<TType, Brand> {
equals(other: Identity<TType, Brand>): boolean {
return this.value === other.value
}
private __brand__: Brand // вся магия тут
}
Попробуем сделать что-то плохое ещё раз:
assignTaskToProject(new TaskId(42), new TaskId(42))
// Error: Argument of type 'TaskId' is not assignable to parameter of type 'ProjectId'. Type '"TaskId"' is not assignable to type '"ProjectId"'.
const taskId = new TaskId(42) as ProjectId
// Error: Conversion of type 'TaskId' to type 'ProjectId' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first. Type '"TaskId"' is not comparable to type '"ProjectId"'
Теперь компилятор будет проверять типы идентификаторов и выдаст ошибку компиляции. Это может уберечь нас от глупых ошибок.