Модель заказа: всё вместе
Дом построен: в нём есть комнаты-модули, правила на стене и инструкции к приборам. Осталось его обставить, и для этого пригодится всё, что было в курсе.
Соберём модель заказа для интернет-магазина. Заказ живёт по таким правилам:
- он бывает новым, оплаченным или отправленным; у оплаченного есть сумма, у отправленного — трек-номер посылки;
- номер заказа после создания не меняется;
- адрес и комментарий исправить можно, а номер — нет;
- сумму вводят в форме, ввод может оказаться неверным, и код обязан это проверить;
- у каждого состояния свой текст статуса, и новое состояние нельзя забыть.
Каждое правило мы запишем в типах. Тогда нарушение заметит tsc при проверке, а не пользователь в работающей программе.
Состояния заказа
Состояния — это размеченное объединение с признаком kind, а номер — поле readonly:
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Давайте разберём пример по строкам:
- В
OrderStateтри варианта, и у каждого свой литерал вkindи свои поля. - В
Orderномер помеченreadonly, а состояние лежит в полеstate. order.state = { kind: "paid", total: 500 }заменяет состояние целиком: заказ стал оплаченным.- Программа печатает
7 paid.
Теперь правила проверяет tsc. Строка order.id = 8 не скомпилируется с ошибкой TS2540: номер только для чтения. А отправленный заказ без трек-номера не подойдёт к OrderState:
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 на выделенной строке.
1type OrderState =2 | { kind: "new" }3 | { kind: "paid"; total: number }4 | { kind: "shipped"; track: string };5 6function statusText(s: OrderState): string {подсказка редактораs: OrderState7 switch (s.kind) {8 case "new":9 return "waiting for payment";10 case "paid":11 return `paid: ${s.total}`;12 case "shipped":13 return `on the way: ${s.track}`;14 default:15 const rest: never = s;16 return rest;17 }18}
На входе s может быть любым из трёх состояний заказа.
Добавьте в OrderState четвёртый вариант { kind: "cancelled" } и ничего не меняйте в statusText. Отменённый заказ попадёт в default, и tsc покажет строку с never:
error TS2322: Type '{ kind: "cancelled"; }' is not assignable to type 'never'.Исправление заказа
Адрес и комментарий исправляет отдельная функция. Состояние в ней не участвует, поэтому в этом примере у заказа номер, адрес и комментарий для курьера:
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Разберём, что здесь происходит:
Omit<Order, "id">убирает номер, и остаютсяaddressиcomment.Partial<…>делает их необязательными:OrderPatch— это{ address?: string; comment?: string }.{ ...order, ...patch }собирает новый заказ, и поля изpatchзаменяют старые.- Программа печатает
Oak 2 7: адрес новый, номер прежний.
Передать номер в исправлении нельзя. Вызов update(order, { id: 99 }) не скомпилируется:
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>: или значение, или текст ошибки.
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Что важно в этом примере:
Result<T>— размеченное объединение с признакомok: у удачного варианта естьvalueтипаT, у неудачного —error.parseTotalвозвращаетResult<number>: на местеTстоитnumber.r.ok ? r.value : r.errorпроверяет признак и сужаетr, как это делаетif.- Для строки
"abc"программа печатаетbad total: abc.
Взять значение без проверки не выйдет. Строка console.log(r.value + 1); не скомпилируется:
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>:
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, прежде чем взять значение.