Discriminated Union — когда наконец-то пригодился дискриминант

Frontend

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

Типизируем «в лоб»

Самый простой способ — написать два интерфейса и сформировать тип результата вызова ручки через их объединение

interface RequestSuccess {
  data: string[]
}

interface RequestError {
  message: string
}

type RequestResult = RequestSuccess | RequestError;

Должно сработать, но вот проблема, тайпскрипт не понимает, что именно будет внутри переменной, в которой мы сохраним результат вызова апи: данные или ошибка? И он не даст нам спокойно обратиться ни к полю data, ни к полю message.

function request(): RequestResult { ... }

const result = request();

// Property 'message' does not exist on type 'RequestResult'
// Property 'message' does not exist on type 'RequestSuccess'
result.message; 

// Property 'data' does not exist on type 'RequestResult'
// Property 'data' does not exist on type 'RequestError'
result.data; // 

Довольно бесполезная получилась типизация. Давайте исправлять

Корректная типизация

Благо, что исправлять много не нужно. Для начала, правда, придется добиться того, чтобы в ответе появилось еще одно поле, общее для всех статусов запроса, но с разными значениями. Возможно, придется об этом договариваться с бэкендерами, возможно, подкрутить в BFF или в той тулзе с помощью которой вы ходите в API.

Словом, мы хотим, чтобы в итоге получилось как-то так

interface RequestSuccess {
  status: "success";
  data: string[];
}

interface RequestError {
  status: "error";
  message: string;
}

type RequestResult = RequestSuccess | RequestError;

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

function request(): RequestResult { ... }

const result = request();

if(result.status === 'error') {
  result.message; // ok
} else {
  result.data; // ok
}

Обратите внимание, что TS достаточно умен, чтобы по значению поля понять, к какому из частей дискриминируемого юниона на самом деле относится объект данных. То есть в ветке if тип переменной result будет определен как RequestSuccess, а в else как RequestError

Альтернативные способы

Читаемость и удобство дискриминанта в типах кажется мне весомой причиной использовать их везде где только возможно. Но а что, если сделать этого вдруг нельзя? Ну, допустим не получается добиться того чтобы в ответе появилось дополнительное поле: бэкендеры экономят байты траффика, BFF не поддается, а времени ковырять инструментарий нет.

Есть как минимум три способа. Один хуже другого, чесслово.

  1. Заменить интерфейсы на классы и применить instanceof
class RequestSuccess {
  data: string[] = [];
}

class RequestError {
  message: string = '';
}

type RequestResult = RequestSuccess | RequestError;

const result = request();

if(result instanceof RequestError) {
  result.message;
} else {
  result.data;
}

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

  1. Написать свой кастомный тайпгард
interface RequestSuccess {
  data: string[];
}

interface RequestError {
  message: string;
}

type RequestResult = RequestSuccess | RequestError;

function isError(response: RequestResult): response is RequestError {
  return 'message' in response;
}

if(isError(result)) {
  result.message;
} else {
  result.data;
}

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

  1. Использовать для сужения типа оператор in
interface RequestSuccess {
  data: string[];
}

interface RequestError {
  message: string;
}

type RequestResult = RequestSuccess | RequestError;

const result = request();

if('message' in result) {
  result.message;
} else {
  result.data;
}

Это пожалуй лучшее решение из альтернативных. Нет лишних сущностей, нет лишнего кода. Дискриминейтед юниону оно проигрывает только человекочитаемостью. Грубо говоря, заходя в ветку if я понимаю что в result есть поле message, но не понимаю (или не сразу понимаю) что внутри этого блока я обрабатываю состояние ошибки. Совсем другое дело когда мы явно проверяем значение поля-дискриминанта.

Но выбирать, конечно, вам.

Симо Мофин
Симо Мофин

Senior Frontend Developer
Главный по блогу