← From JavaScript to Fastify: one Task API, four levels
02 / 04 · 75 min

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

Что реально делает выражение value as Task?

01Что реально делает выражение value as Task?

Зачем использовать unknown вместо any на внешней границе?

02Зачем использовать unknown вместо any на внешней границе?

Что даёт never в default-ветке switch?

03Что даёт never в default-ветке switch?

Sources

TypeScript from JavaScript: model states, don’t hide errors · From JavaScript to Fastify: one Task API, four levels · VORCL