Перейти к содержимому
Теория · Проект на TypeScript · урок 9 из 10

Модель заказа: всё вместе

Дом построен: в нём есть комнаты-модули, правила на стене и инструкции к приборам. Осталось его обставить, и для этого пригодится всё, что было в курсе.

Соберём модель заказа для интернет-магазина. Заказ живёт по таким правилам:

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

Каждое правило мы запишем в типах. Тогда нарушение заметит tsc при проверке, а не пользователь в работающей программе.

Состояния заказа

Состояния — это размеченное объединение с признаком kind, а номер — поле readonly:

TypeScript
type OrderState =
    | { kind: "new" }
    | { kind: "paid"; total: number }
    | { kind: "shipped"; track: string };

interface Order {
    readonly id: number;
    address: string;
    state: OrderState;
}

const order: Order = { id: 7, address: "Elm 5", state: { kind: "new" } };
order.state = { kind: "paid", total: 500 };
console.log(order.id, order.state.kind); // 7 paid

Давайте разберём пример по строкам:

  1. В OrderState три варианта, и у каждого свой литерал в kind и свои поля.
  2. В Order номер помечен readonly, а состояние лежит в поле state.
  3. order.state = { kind: "paid", total: 500 } заменяет состояние целиком: заказ стал оплаченным.
  4. Программа печатает 7 paid.

Теперь правила проверяет tsc. Строка order.id = 8 не скомпилируется с ошибкой TS2540: номер только для чтения. А отправленный заказ без трек-номера не подойдёт к OrderState:

TEXT
error TS2322: Type '{ kind: "shipped"; }' is not assignable to type 'OrderState'.
  Property 'track' is missing in type '{ kind: "shipped"; }' but required in type '{ kind: "shipped"; track: string; }'.

Текст статуса

Текст статуса строит switch по признаку. Каждый case сужает состояние до своего варианта, а в default стоит проверка полноты через never. Нажимайте «Следующий шаг» и смотрите, какой тип у s на выделенной строке.

ВживуюЧто видит TypeScript в каждом caseшаг 1 из 5

На входе s может быть любым из трёх состояний заказа.

Нажимайте «Следующий шаг»: подсказка покажет тип на выделенной строке

Добавьте в OrderState четвёртый вариант { kind: "cancelled" } и ничего не меняйте в statusText. Отменённый заказ попадёт в default, и tsc покажет строку с never:

TEXT
error TS2322: Type '{ kind: "cancelled"; }' is not assignable to type 'never'.

Исправление заказа

Адрес и комментарий исправляет отдельная функция. Состояние в ней не участвует, поэтому в этом примере у заказа номер, адрес и комментарий для курьера:

TypeScript
interface Order {
    readonly id: number;
    address: string;
    comment: string;
}

type OrderPatch = Partial<Omit<Order, "id">>;

function update(order: Order, patch: OrderPatch): Order {
    return { ...order, ...patch };
}

const order: Order = { id: 7, address: "Elm 5", comment: "" };
const next = update(order, { address: "Oak 2" });
console.log(next.address, next.id); // Oak 2 7

Разберём, что здесь происходит:

  1. Omit<Order, "id"> убирает номер, и остаются address и comment.
  2. Partial<…> делает их необязательными: OrderPatch — это { address?: string; comment?: string }.
  3. { ...order, ...patch } собирает новый заказ, и поля из patch заменяют старые.
  4. Программа печатает Oak 2 7: адрес новый, номер прежний.

Передать номер в исправлении нельзя. Вызов update(order, { id: 99 }) не скомпилируется:

TEXT
error TS2353: Object literal may only specify known properties, and 'id' does not exist in type 'Partial<Omit<Order, "id">>'.

Заметьте: Partial делает свойства необязательными, но пометку readonly не снимает. Если объявить const p: Partial<Order> = {}, свойство id в нём тоже только для чтения, и p.id = 8 — ошибка TS2540.

Результат с ошибкой

Сумма приходит из формы строкой, и прочитать её получится не всегда. Поэтому функция возвращает обобщённый тип Result<T>: или значение, или текст ошибки.

TypeScript
type Result<T> =
    | { ok: true; value: T }
    | { ok: false; error: string };

function parseTotal(text: string): Result<number> {
    const total = Number(text);
    if (Number.isNaN(total) || total <= 0) {
        return { ok: false, error: `bad total: ${text}` };
    }
    return { ok: true, value: total };
}

const r = parseTotal("abc");
console.log(r.ok ? r.value : r.error); // bad total: abc

Что важно в этом примере:

  1. Result<T> — размеченное объединение с признаком ok: у удачного варианта есть value типа T, у неудачного — error.
  2. parseTotal возвращает Result<number>: на месте T стоит number.
  3. r.ok ? r.value : r.error проверяет признак и сужает r, как это делает if.
  4. Для строки "abc" программа печатает bad total: abc.

Взять значение без проверки не выйдет. Строка console.log(r.value + 1); не скомпилируется:

TEXT
error TS2339: Property 'value' does not exist on type 'Result<number>'.
  Property 'value' does not exist on type '{ ok: false; error: string; }'.

Код, который вызвал parseTotal, обязан сначала посмотреть на ok. Так правило «ввод нужно проверить» записано в типе.

Частая ошибка

Исправление описали типом Partial<Order>:

TypeScript
interface Order {
    readonly id: number;
    address: string;
    comment: string;
}

function update(order: Order, patch: Partial<Order>): Order {
    return { ...order, ...patch };
}

const order: Order = { id: 7, address: "Elm 5", comment: "" };
const next = update(order, { id: 99 }); // ✗
console.log(next.id);

tsc молчит: в Partial<Order> свойство id есть, и { id: 99 } к нему подходит. Пометка readonly здесь не помогает. Она запрещает только присваивание вроде order.id = 99, а update собирает новый объект, и номер в нём берётся из patch. Программа напечатает 99, и у заказа сменится номер. Уберите номер из типа исправления: Partial<Omit<Order, "id">>.

Тренажёр собеседований

Модель заказа собрала курс целиком: размеченные объединения, readonly, утилитные типы, дженерики и проверку полноты. Похожую модель могут попросить набросать и на собеседовании, поэтому следующий шаг — тренажёр собеседований по TypeScript на этом сайте. Его открывает кнопка «Пройти собеседование по TypeScript» на странице завершения курса. Тренажёр спрашивает и о темах курса, и о тех, которых курс не касался, и оценивает ответы.

Главное

  • Состояния заказа — размеченное объединение: у каждого варианта свой литерал в kind и свои поля.
  • switch по признаку сужает состояние в каждом case, а never в default не даёт забыть новый вариант.
  • Partial<Omit<Order, "id">> описывает исправление без номера; Partial оставляет readonly, но не мешает передать id в новом объекте.
  • Result<T> заставляет вызывающий код проверить ok, прежде чем взять значение.