TypeScript from JavaScript: model states, don’t hide errors
Migrating Task Core to strict TypeScript: domain types, narrowing, discriminated unions, unknown and a validated boundary for external data.
Lesson content is currently available in Russian.
What you will learn
- →Включить strict и объяснить, какие дефекты он переносит из runtime в редактор и CI.
- →Описывать допустимые состояния задачи union-типами, а не произвольными строками.
- →Сужать unknown на внешней границе без any и небезопасных утверждений as.
- →Создать общий Result-контракт для успешного ответа и доменной ошибки.
strict — договор команды, не украшение редактора
TypeScript не проверяет данные в runtime: он анализирует исходный код до запуска. Его ценность раскрывается, когда строгий режим не позволяет молча протащить undefined, неявный any или несовместимую функцию. Начинайте миграцию с маленького доменного модуля, а не с механической замены расширений во всём проекте.
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noUncheckedIndexedAccess": true,
"outDir": "dist"
},
"include": ["src/**/*.ts"]
}
strict объединяет несколько строгих проверок, включая strictNullChecks. noUncheckedIndexedAccess дополнительно напоминает: элемент по индексу может отсутствовать. Не выключайте правило при первой красной строке. Ошибка компилятора часто сообщает о неописанном бизнес-сценарии: что делать, если задача не найдена?
Union-типы делают нелегальные состояния непредставимыми
В JavaScript статус был строкой и проверялся вручную. В TypeScript перечислим только допустимые значения и разделим вход создания от сохранённой сущности. Это не заменяет runtime-валидацию JSON, но не даёт внутреннему коду случайно присвоить typo.
export type TaskStatus = "todo" | "doing" | "done";
export interface Task {
id: string;
title: string;
status: TaskStatus;
createdAt: string;
}
export type CreateTaskInput = Pick<Task, "title">;
export function setStatus(task: Task, status: TaskStatus): Task {
return { ...task, status };
}
Избегайте enum только ради привычки: строковый union прост, сериализуется без дополнительного runtime-объекта и хорошо сужается. Ошибка новичка — сделать все поля optional, чтобы компилятор замолчал. Такой тип перестаёт описывать сущность и переносит проверки обратно в каждый вызов.
unknown на входе, narrowing до доменного типа
Тело HTTP-запроса, JSON из файла и ответ внешнего API нельзя считать Task только потому, что мы написали as Task. Утверждение as ничего не проверяет в runtime. Честный тип внешних данных — unknown; затем type guard доказывает минимальный контракт.
export function parseCreateTask(value: unknown): CreateTaskInput {
if (typeof value !== "object" || value === null) {
throw new Error("TASK_BODY_INVALID");
}
const title = Reflect.get(value, "title");
if (typeof title !== "string" || title.trim().length === 0) {
throw new Error("TASK_TITLE_REQUIRED");
}
return { title: title.trim() };
}
Narrowing следует из проверок control flow: после typeof компилятор знает тип title. any выключил бы эту защиту и заразил следующие вызовы. На Fastify-уроке JSON Schema возьмёт runtime-проверку на себя, но принцип останется тем же: внешняя граница должна доказать тип до входа в домен.
Discriminated union для результата и исчерпывающая обработка
Исключения удобны внутри небольшого слоя, но на границе полезен явный результат. Discriminated union связывает флаг ok с доступными полями. Ветка ошибки не может случайно обратиться к data, а новая категория ошибки заставит обновить switch.
type TaskErrorCode = "NOT_FOUND" | "INVALID_STATUS";
type Result<T> =
| { ok: true; data: T }
| { ok: false; code: TaskErrorCode };
export function toHttpStatus(result: Result<Task>): number {
if (result.ok) return 200;
switch (result.code) {
case "NOT_FOUND": return 404;
case "INVALID_STATUS": return 400;
default: {
const impossible: never = result.code;
return impossible;
}
}
}
Проверка never превращает забытый кейс в ошибку компиляции. Это важнее длинной таблицы интерфейсов: хороший тип кодирует решение продукта. Capstone после урока должен компилироваться с tsc --noEmit без any, ts-ignore и необоснованных as.
Practice
Task Core v2: строгая миграция
Перенесите Task Core в TypeScript. Добавьте TaskStatus, отдельный CreateTaskInput, parseCreateTask(unknown) и Result для get/setStatus. Включите strict и noUncheckedIndexedAccess.
Deliverable
Исходники .ts, tsconfig.json и команда tsc --noEmit. Отдельный файл type-errors.ts с тремя закомментированными примерами, которые компилятор обязан отклонить.
Checkpoint
Покажите, где заканчиваются непроверенные данные и начинается доверенный доменный тип.
- ✓ Внешний JSON имеет тип unknown до runtime-проверки.
- ✓ Нет any, ts-ignore и утверждения as Task вместо валидации.
- ✓ TaskStatus исключает произвольную строку на уровне компиляции.
- ✓ Result обрабатывается исчерпывающе через discriminant и never.
Check yourself
Sources