Проверка неизвестных данных: `unknown`, сужение типов, проверка массива и полей, неизменяемый результат и атомарный отказ.
anyОтвет сервера не становится безопасным только потому, что мы написали рядом имя типа.
Представьте: вы написали const data: Product[] = await response.json(). Компилятор молчит. Затем API отдаёт null или { error: "rate limit" } — приложение падает без объяснений. unknown заставляет проверить данные до того, как они попадут в компонент.
Напишем функцию normalizeProducts(value: unknown). Она примет неизвестное значение, проверит массив и каждую запись, вернёт новые объекты товаров или выбросит TypeError, не оставляя частичного результата.
Этот контракт совпадает с лабораторной работой. В предыдущем уроке мы копировали объекты через spread, чтобы не портить исходные данные — здесь мы продолжим эту практику, но на границе с внешним миром.
unknownTypeScript проверяет программу до запуска, но не управляет ответом сервера. Даже документированный API может вернуть пустое тело, страницу ошибки или данные старой версии.
// src/api.ts
async function loadProducts(signal: AbortSignal): Promise<unknown> {
const response = await fetch('/api/products', { signal });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
}unknown означает: «значение есть, но его форму ещё нужно доказать». В отличие от any, этот тип не разрешит сразу обратиться к полю или вызвать метод.
const value: unknown = await loadProducts(signal);
// Ошибка TypeScript: форма value пока неизвестна.
console.log(value[0].title);Это полезная ошибка. Она не даёт случайно распространить недоверенные данные по приложению.
Лаборатория ожидает массив товаров. Первым делом отсекаем всё остальное:
// src/normalizeProducts.ts
function normalizeProducts(value: unknown): readonly Product[] {
if (!Array.isArray(value)) {
throw new TypeError('products must be an array');
}
// Здесь value уже имеет тип unknown[].
return value.map(parseProduct);
}После Array.isArray компилятор сужает тип value до массива неизвестных элементов. Это и есть сужение типа: проверка в коде даёт TypeScript новую информацию для текущей ветви.
Проверьте себя. Передайте null, объект и строку. Во всех трёх случаях функция должна выбросить TypeError до обхода элементов.
У unknown нельзя читать свойства. Сначала убеждаемся, что перед нами объект, и отдельно исключаем null, потому что typeof null возвращает строку "object".
После проверки typeof value === 'object' TypeScript знает, что перед нами объект, но не какие поля в нём есть. Приведение к Record<string, unknown> даёт читать произвольные ключи — каждый всё ещё unknown.
// src/normalizeProducts.ts
type Product = {
id: string;
title: string;
price: number;
};
function parseProduct(value: unknown): Product {
if (typeof value !== 'object' || value === null) {
throw new TypeError('product must be an object');
}
const row = value as Record<string, unknown>;
if (
typeof row.id !== 'string' ||
typeof row.title !== 'string' ||
typeof row.price !== 'number' ||
!Number.isFinite(row.price) ||
row.price < 0
) {
throw new TypeError('invalid product');
}
return { id: row.id, title: row.title, price: row.price };
}Обратите внимание на цену: сначала проверяется typeof row.price === 'number', и только после этого вызывается Number.isFinite. Это решает две задачи одновременно: Number.isFinite принимает только число, поэтому TypeScript требует сначала убедиться в типе; а во время выполнения проверка отсекает строки, null и undefined до вызова функции, которая ожидает число.
Если одна из десяти записей повреждена, функция выбрасывает исключение и не возвращает девять хороших. Это называется атомарный результат: или всё прошло проверку, или ничего. Иначе вызывающий код получит смесь доверенных и недоверенных данных.
map(parseProduct) сначала создаёт новый объект для каждой корректной записи. Если одна запись ошибочна, parseProduct выбрасывает исключение, и normalizeProducts не возвращает частичный массив.
const input = [
{ id: 'p1', title: 'Клавиатура', price: 99 },
{ id: 2, title: 'Мышь', price: 40 },
];
normalizeProducts(input); // TypeError: invalid productФункция не меняет input. Даже успешно проверенные записи копируются в новые объекты. После возвращения результата код приложения больше не зависит от того, кто ещё хранит ссылку на исходный JSON.
Весь поток проверки выглядит так:
unknown
↓ Array.isArray?
↓ нет → TypeError
unknown[]
↓ typeof === 'object' && !== null?
↓ нет → TypeError
Record<string, unknown>
↓ typeof id/title === 'string'?
↓ typeof price === 'number' && isFinite && >= 0?
↓ нет → TypeError
Product
Проверьте себя. Сохраните ссылку на первый входной объект и убедитесь, что соответствующий товар результата равен ему по содержимому, но не по ссылке.
ReadonlyПредставьте: normalizeProducts вернула проверенный массив. Коллега пишет компонент и случайно меняет цену товара через products[0].price = 0. Компилятор молчит — объект не заморожен, TypeScript не знает, что менять нельзя.
TypeScript предлагает два уровня защиты:
Уровень 1: readonly перед массивом — запрещает менять сам массив. Это уже стоит в сигнатуре normalizeProducts:
const products = normalizeProducts(payload);
products.push({ id: 'p2', title: 'Мышь', price: 40 }); // ошибка типов
products[0].price = 0; // пока работает!push запрещён, но products[0].price = 0 — нет: readonly Product[] защищает массив, но не его элементы.
Уровень 2: Readonly вокруг типа — запрещает менять поля каждого объекта. Это встроенный утилитарный тип TypeScript, который делает все свойства доступными только для чтения:
type Product = Readonly<{
id: string;
title: string;
price: number;
}>;
// теперь и products[0].price = 0 — ошибка типовЭто защита при компиляции, а не заморозка JavaScript-объекта. Object.freeze делает то же самое, но поверхностно и без рекурсивного обхода. Для типизации readonly надёжнее и проще.
В лабораторной работе readonly в возвращаемом типе — это контракт: функция обещает, что результат нельзя изменить через её ссылку.
Реализуйте экспорт:
export function normalizeProducts(value: unknown): readonly Product[];Требования:
id и title;price — конечное неотрицательное число;TypeError;Запустите проверку:
LAB_MODE=starter npm test -- modern_js_tsПосле прохождения открытых тестов добавьте свой случай: null, NaN, отрицательную цену, отсутствующее поле или смешанный массив с ошибкой в последней записи.
Далее: Инструментарий React-проекта