Главная / Статьи / Моделирование доменов в TypeScript: за пределами базовых аннотаций типов

Моделирование доменов в TypeScript: за пределами базовых аннотаций типов

Изучите практические подходы к использованию TypeScript — от различий между типами unknown и any до конструкций дискриминированных союзов и функции satisfies — которые помогут вам моделировать допустимые состояния, а не просто маркировать данные.

2330 слов

TypeScript на самом деле довольно прост в освоении.

Сначала вы изучаете интерфейсы.

Затем — псевдонимы типов.

После этого появляются объединения типов, генерики, вспомогательные типы и иногда маппинговые типы.

Вскоре вы сможете взглянуть на обычный объект JavaScript и без колебаний присвоить ему тип.

Но в какой-то момент работа с TypeScript перестает сводиться к присваиванию типов объектам.

Она превращается в целенаправленное проектирование типов.

Это совершенно другой навык, который необходимо развивать.

Посмотрите на этот пример:

type Payment = {
  status: 'SUCCESS' | 'FAILED'
  transactionId?: string
  error?: string
}

На первый взгляд всё кажется в порядке.

Но подумайте, какие состояния технически допускается этот тип.

Он допускает все следующие:

{
  status: 'SUCCESS'
}

{
  status: 'SUCCESS',
  error: 'Something went wrong'
}

{
  status: 'FAILED',
  transactionId: '123'
}

{
  status: 'FAILED',
  error: 'Something went wrong'
}

У этого типа нет представления о том, какие комбинации на самом деле имеют смысл вместе.

Это не ограничение самого TypeScript.

Это признак того, что домен был плохо спроектирован.

Лучшая версия выглядит так:

type Payment =
  | {
      status: 'SUCCESS'
      transactionId: string
    }
  | {
      status: 'FAILED'
      error: string
    }

Теперь система типов напрямую кодирует реальные бизнес-правила.

У успешной оплаты должен быть идентификатор транзакции.

У неудачной оплаты должно быть сообщение об ошибке.

Комбинации, которые не имеют смысла, становятся сложными или вообще невозможными для создания.

Именно здесь TypeScript начинает действительно пригождаться.

Речь не о том, чтобы размещать аннотации типов везде, где только можно.

Речь о том, чтобы ваши типы отражали правила, которых действительно придерживается ваше приложение.

Ниже приведены несколько привычек, которые помогут двигаться в этом направлении.

1. Прекратите использовать any, когда на самом деле имеете в виду «Я не знаю»

Один из самых быстрых способов устранить замечания TypeScript — это:

const response: any = await fetchData()

Иногда действительно происходит именно так.

Вы сталкиваетесь с ошибкой.

Вы находитесь в процессе реализации.

Вы не можете сразу определить правильный тип.

Поэтому используется any.

Компилятор замолкает.

Но пропадает и вся помощь, которую предоставлял TypeScript.

Как только any проникает в ваш код:

const response: any = await fetchData()

response.user.profile.name // not checked
response.foo.bar.baz // not checked

У TypeScript нет способа обнаружить ни одну из этих ошибок.

Используйте unknown, когда значение действительно неизвестно

const response: unknown = await fetchData()

Это заставляет вас фактически определить, что представляет собой значение, прежде чем использовать его.

if (typeof response === 'string') {
  console.log(response.toUpperCase())
}

Для всего, что сложнее примитива, лучше проверять структуру на границе.

Здесь важна эта разница:

unknown говорит: «Я пока этого не знаю». any говорит: «Я совсем не хочу, чтобы TypeScript проверял это».

Это две совершенно разные цели.

При работе с данными, поступающими извне вашей системы, unknown почти всегда является более точной отправной точкой.

2. Не пишите то, что TypeScript уже знает

Написание строго типизированного кода не означает ручной аннотации каждой переменной.

Эта версия:

const name: string = 'Akshat'
const age: number = 30
const active: boolean = true

не является по своей сути лучше этой:

const name = 'Akshat'
const age = 30
const active = true

TypeScript может самостоятельно определить эти типы.

Аннотация всего лишь увеличивает визуальную загруженность без добавления реальной информации.

Явные аннотации имеют смысл тогда, когда они передают что-то значимое.

Например:

function calculateTotal(
  items: Product[],
  discount: number
): number {
  // ...
}

Здесь сигнатура функции фактически задокументирована как часть контракта.

Это действительно полезная информация.

Полезной проверкой будет:

Говорит ли эта аннотация TypeScript о чем-то, что он не мог выяснить самостоятельно?

Если ответ «нет», её, скорее всего, можно убрать.

3. Используйте as const, когда значения также являются типами

Рассмотрим объект вот такого вида:

const STATUS = {
  ACTIVE: 'ACTIVE',
  INACTIVE: 'INACTIVE',
}

Иногда вы хотите, чтобы отдельные значения оставались в своих первоначальных типах, а не преобразовывались в string.

Именно это и позволяет использовать as const:

const STATUS = {
  ACTIVE: 'ACTIVE',
  INACTIVE: 'INACTIVE',
} as const

Далее:

type Status = typeof STATUS[keyof typeof STATUS]

результат будет следующим:

'ACTIVE' | 'INACTIVE'

Этот паттерн особенно полезен, когда вам нужны как значения во время выполнения, так и соответствующий тип на этапе компиляции из одного и того же определения.

Например:

export const ALTERNATE_CODE_TYPES = {
  CHARGE_CODE: 'CHARGE_CODE',
  NFTP_MDG_CODE: 'NFTP_MDG_CODE',
  FACT_MDG_CODE: 'FACT_MDG_CODE',
  CW1_CHARGE_CODE: 'CW1_CHARGE_CODE',
} as const

export type AlternateCodeType =
  typeof ALTERNATE_CODE_TYPES[keyof typeof ALTERNATE_CODE_TYPES]

Здесь сам объект и производный тип имеют общее происхождение.

Это позволяет избежать необходимости в отдельной декларации, подобной:

type AlternateCodeType =
  | 'CHARGE_CODE'
  | 'NFTP_MDG_CODE'
  | 'FACT_MDG_CODE'
  | 'CW1_CHARGE_CODE'

дополнительно к ней.

Сохранение единого источника правды гораздо проще в управлении, чем ручное синхронизирование двух определений.

4. Используйте типы-объединения, когда домен имеет фиксированный набор состояний

Когда значение может принимать лишь ограниченное количество значений, ваши типы должны это прямо указывать.

Вместо того чтобы писать:

function setStatus(status: string) {
  // ...
}

лучше использовать:

type Status = 'pending' | 'approved' | 'rejected'

function setStatus(status: Status) {
  // ...
}

С этим подходом следующий вызов работает корректно:

setStatus('approved')

но этот вызов отклоняется:

setStatus('something-else')

Чем строже определён тип, тем больше работы может выполнить за вас компилятор.

Эта выгода выходит далеко за рамки автодополнения в редакторе. Точное объединение также помогает при:

  • рефакторинге
  • создании документации
  • обнаружении ошибок
  • проектировании API
  • улучшении доступности

Если ваша бизнес-логика действительно позволяет только три возможных значения, не представляйте это поле в виде свободной строки.

5. Не используйте enum автоматически

Enum иногда бывают подходящим инструментом, но они не должны становиться вашим стандартным выбором для каждой группы констант.

Если вам нужно лишь объединение на этапе компиляции, достаточно чего-то вроде:

type Status = 'ACTIVE' | 'INACTIVE'

этого часто бывает достаточно.

Если вам также необходимо, чтобы эти значения существовали во время выполнения программы, используйте:

const STATUS = {
  ACTIVE: 'ACTIVE',
  INACTIVE: 'INACTIVE',
} as const

type Status = typeof STATUS[keyof typeof STATUS]

Это позволяет вам работать как с типом, так и с реальным объектом.

Ключевой момент, который необходимо усвоить, заключается в том, что типы TypeScript исчезают после запуска кода. Обычный объект же остается.

Поэтому следует задать себе вопрос:

Должна ли эта переменная существовать во время выполнения, или она нужна только для ограничений на этапе компиляции?

Выберите подход, соответствующий ответу.

6. Сделать невозможным представление недопустимых состояний

Это, возможно, самая ценная идея во всем этом обсуждении.

Представьте компонент формы, который может находиться в одном из следующих состояний:

  • loading
  • ready
  • submitting
  • successful
  • failed

Типичный, но ошибочный способ моделирования этого выглядит так:

type FormState = {
  loading: boolean
  submitting: boolean
  error?: string
  data?: FormData
}

При такой структуре ничто не мешает случайно получить что-то вроде:

{
  loading: true,
  submitting: true,
  data: {...},
  error: 'Something went wrong'
}

Что на самом деле означает эта комбинация? Система типов не имеет понятия, и тот же вопрос останется без ответа у следующего разработчика, который её прочитает.

Лучшая структура связывает поля между собой на основе состояния:

type FormState =
  | { status: 'loading' }
  | { status: 'ready'; data: FormData }
  | { status: 'submitting'; data: FormData }
  | { status: 'success'; data: FormData }
  | { status: 'error'; error: string }

Теперь каждая ветвь содержит именно те данные, которые для неё имеют смысл.

function render(state: FormState) {
  switch (state.status) {
    case 'loading':
      return 'Loading...'
    case 'ready':
      return state.data
    case 'submitting':
      return 'Submitting...'
    case 'success':
      return state.data
    case 'error':
      return state.error
  }
}

В этом и заключается преимущество дискриминированных союзов.

Вместо того чтобы моделировать приложение как набор независимых логических значений и необязательных полей, вы моделируете его как фиксированный набор допустимых состояний. Это гораздо надежнее основание.

7. Будьте осторожны с необязательными свойствами

Необязательные поля имеют свои применения, но они также являются удобным способом тайно внедрить неопределенность, оставаясь незамеченным.

Возьмем этот пример:

type User = {
  id?: string
  name?: string
  email?: string
}

Согласно этому определению, каждый фрагмент кода, использующий объект User, теперь должен обрабатывать ситуацию, когда ни одно из этих полей отсутствует.

Но, возможно, настоящее правило в данной области заключается в следующем:

У каждого пользователя всегда есть идентификатор, имя и адрес электронной почты.

Если это так, то моделируйте данные именно так:

type User = {
  id: string
  name: string
  email: string
}

Необязательные свойства должны отражать поля, которые действительно иногда отсутствуют. Они не предназначены для того, чтобы заменять собой расплывчатое признание того, что автор типа не был уверен в том, что именно вернёт API.

Если неопределённость происходит из какой-либо внешней системы, обрабатывайте её прямо на границе взаимодействия. Не позволяйте неопределённости, возникшей в одной из интеграций, распространяться по всему кодовому базису.

8. Понимание различий между null и undefined

На практике эта разница оказывается более значимой, чем предполагают люди.

Возьмём такой пример:

type User = {
  middleName: string | null
}

Такая формулировка подразумевает:

Поле существует, но для него умышленно не задано значение.

А теперь сравним её с:

type User = {
  middleName?: string
}

что обычно означает:

Этого поля вообще может не быть.

Это различие особенно важно при работе с API. В запросе типа PATCH такое тело:

{
  middleName: null
}

может означать:

Удалить существующее второе имя.

в то время как такое тело:

{}

может означать:

Не менять второе имя.

Если типы не могут отразить эту разницу, на уровне API могут появляться скрытые ошибки.

Помните, что типы существуют для передачи смысла, а не просто для удовлетворения требований компилятора.

9. Используйте satisfies вместо слепого утверждения типов

Рассмотрим тип конфигурации примерно такой:

type Config = {
  timeout: number
  retries: number
}

Один из вариантов — написать:

const config = {
  timeout: 5000,
  retries: 3,
} as Config

Но as является утверждением, и его использование по сути говорит компилятору принять значение без вопросов.

Более правильный подход — это:

const config = {
  timeout: 5000,
  retries: 3,
} satisfies Config

В этой версии TypeScript действительно проверяет, соответствует ли объект типу Config, при этом сохраняя более узкий, автоматически определенный тип самого объекта.

Простой способ запомнить разницу:

as

Рассматривайте это значение как принадлежащее этому типу.

satisfies

Проверьте, соответствует ли это значение требованиям этого типа.

Именно поэтому метод satisfies особенно удобен для объектов конфигурации, статических сопоставлений и таблиц поиска.

10. Рассматривайте as как границу, а не как стандартный инструмент

Бывают случаи, когда действительно требуется подтверждение типа. Но написание чего-то вроде этого:

const user = response as User

на самом деле ничего не проверяет во время выполнения.

Предположим, вызов API действительно возвращает:

{
  username: 'akshat'
}

У TypeScript нет способа обнаружить несоответствие, потому что операция подтверждения уже указала ему принимать значение без дополнительной проверки. Компилятору здесь ничего не доказывается — от него просто просят игнорировать этот факт.

Это становится рискованным там, где данные поступают извне кодовой базы, например:

  • ответы API
  • localStorage
  • параметры URL
  • переменные окружения
  • ввод от пользователя
  • библиотеки сторонних разработчиков

Каждый раз, когда данные поступают в приложение из источника, недоступного для TypeScript, целесообразно проверить эти данные, а не применять к ним преобразования. Проверка схемы во время выполнения может подтвердить следующее:

«Эти данные действительно соответствуют формату, ожидаемому приложением».

Это гораздо надежнее, чем просто написать:

value as User

TypeScript — это инструмент времени компиляции, причем очень хороший. Он изначально не предназначался для проверки того, что происходит во время выполнения программы.

Настоящая цель: моделирование домена

Как только вы освоите такой подход, TypeScript перестанет казаться просто упражнением в синтаксисе. Вместо того чтобы спрашивать «как мне задать тип этого объекта?», вы начинаете спрашивать «в каких состояниях может находиться этот объект?». Вместо вопроса «должна ли эта свойство быть необязательной?» вы задаётесь вопросом «действительно ли это свойство необязательное, или оно просто скрывает что-то, что пока неизвестно?». Вместо вопроса «можно ли здесь использовать as?» вы спрашиваете «можно ли действительно доказать, что у этого значения указанный тип?»

Именно в этом и заключается суть. Написание качественного кода на TypeScript — это не накопление дополнительных аннотаций типов, а обеспечение того, чтобы типы, которые вы всё же указываете, действительно имели значение.

Простое правило, которое стоит запомнить

Каждый раз, когда вы проектируете тип, задайте себе три вопроса:

1. Какие состояния действительно допустимы?

Если тип позволяет представлять недопустимые состояния, вероятно, саму модель необходимо переосмыслить.

2. Что уже известно компилятору?

Избегайте добавления аннотаций из привычки — пусть механизмы вывода выполняют ту работу, которую они уже способны сделать.

3. Где эти данные становятся надежными?

Чем дальше данные перемещаются от своего исходного внешнего источника, тем большую степень определенности должны иметь их типы.

Мощный TypeScript определяется не степенью сложности его типов, а тем, что такие типы делают правильную реализацию очевидной, а неправильную — неудобной для написания.

Как только типы проектируются с таким подходом, TypeScript перестает казаться просто слоем, добавленным к JavaScript. Он становится частью процесса создания приложения.

Связанные материалы

  • Освойте встроенные типы-утилиты TypeScript для более чистого кода — Узнайте, как такие типы-утилиты TypeScript, как Partial, Pick, Omit и Record, устраняют дублирующиеся интерфейсы и автоматически синхронизируют определения типов.