Глава 8. TypeScript на практике: типы в реальном проекте

44 просмотров
0 лайков
0 в избранном

Объединения (union)

Переменная может принимать значения нескольких типов. Знак | читается как «или»:

function printId(id: number | string): void {
    console.log(`ID: ${id}`);
}

printId(42);    // OK
printId("abc"); // OK
// printId(true); // Ошибка

Литеральные типы

Когда значение ограничено набором конкретных строк или чисел:

type Status = "new" | "in_progress" | "done";
type Dice = 1 | 2 | 3 | 4 | 5 | 6;

function setStatus(s: Status): void { /* ... */ }

setStatus("new");
// setStatus("closed"); // Ошибка: "closed" нет в объединении

as const — превращение массива в литеральный тип

const ROLES = ["admin", "editor", "viewer"] as const;
type Role = typeof ROLES[number]; // "admin" | "editor" | "viewer"

function checkAccess(role: Role): boolean {
    return role === "admin";
}

Narrowing (сужение типа)

TypeScript сужает тип внутри условных блоков. Это называется narrowing — основа безопасной работы с объединениями.

Type guards через typeof

function process(value: number | string): number {
    if (typeof value === "number") {
        return value * 2;        // здесь value: number
    }
    return value.length;         // здесь value: string
}

instanceof для классов

class ValidationError extends Error {}
class NetworkError extends Error {}

function handle(err: Error): void {
    if (err instanceof ValidationError) {
        console.log("Проверка ввода:", err.message);
    } else if (err instanceof NetworkError) {
        console.log("Сеть:", err.message);
    }
}

in для проверки свойства

type Shape =
    | { kind: "circle"; radius: number }
    | { kind: "square"; size: number };

function area(shape: Shape): number {
    switch (shape.kind) {
        case "circle":
            return Math.PI * shape.radius ** 2;
        case "square":
            return shape.size ** 2;
    }
}

Это дискриминируемое объединение — приём с общим полем (kind), которое определяет форму объекта. Идеально для конечных автоматов, событий, состояний.

Пользовательские type guards

Когда проверка сложнее typeof, описывают функцию-предикат с возвращаемым типом value is Type:

interface Fish {
    swim(): void;
}
interface Bird {
    fly(): void;
}

function isFish(pet: Fish | Bird): pet is Fish {
    return (pet as Fish).swim !== undefined;
}

function move(pet: Fish | Bird): void {
    if (isFish(pet)) {
        pet.swim(); // TS знает: pet — Fish
    } else {
        pet.fly();
    }
}

Assertion functions

Функция, которая бросает ошибку, если условие не выполнено. Помечается возвращаемым типом asserts condition:

function assertDefined<T>(value: T | undefined | null): asserts value is T {
    if (value === undefined || value === null) {
        throw new Error(`Ожидалось значение, получено ${value}`);
    }
}

const user = getUser();
assertDefined(user);
console.log(user.name); // TS знает: user — User, а не User | undefined

Это удобно для валидации входных данных в начале функции — дальше код пишется без лишних проверок.

Utility-типы

TypeScript поставляется с набором встроенных утилит для преобразования типов. Самые ходовые:

Pick — выбрать поля

interface User {
    id: number;
    name: string;
    email: string;
    passwordHash: string;
}

type UserPreview = Pick<User, "id" | "name">;
// { id: number; name: string }

Omit — исключить поля

type UserPublic = Omit<User, "passwordHash">;
// { id: number; name: string; email: string }

Partial — все поля необязательными

type UserUpdate = Partial<User>;
// все поля необязательны — удобно для PATCH-запросов

Required — наоборот, все обязательны

interface Article {
    title: string;
    subtitle?: string;
}

type StrictArticle = Required<Article>; // subtitle становится обязательным

Readonly — неизменяемый объект

type FrozenUser = Readonly<User>;
const u: FrozenUser = { ... };
// u.name = "x"; // Ошибка

Record — словарь

type UsersById = Record<number, User>;
const users: UsersById = {
    1: { id: 1, name: "Анна", email: "a@x.ru", passwordHash: "..." },
};

ReturnType — тип возвращаемого значения функции

function getUser() {
    return { id: 1, name: "Анна" };
}

type User = ReturnType<typeof getUser>;
// { id: number; name: string }

Parameters — тип параметров функции

type Args = Parameters<typeof getUser>;
// []

Паттерн: DTO и модель

Часто форма данных «на вход» отличается от «внутри системы». Utility-типы помогают не дублировать описания:

interface User {
    id: number;
    name: string;
    email: string;
    createdAt: string;
}

// То, что присылает клиент при создании (без id и createdAt)
type CreateUserDTO = Omit<User, "id" | "createdAt">;

// То, что присылает клиент при обновлении (все поля необязательны)
type UpdateUserDTO = Partial<CreateUserDTO>;

// Что возвращаем по API (без внутреннего поля)
type UserResponse = Omit<User, "email">;

function createUser(dto: CreateUserDTO): User {
    return {
        ...dto,
        id: nextId(),
        createdAt: new Date().toISOString(),
    };
}

Типизация данных «снаружи»

Главная опасность: TypeScript не проверяет данные из API, localStorage, JSON-файлов. Аннотация — это обещание, а не гарантия:

// ОПАСНО: реальный объект может быть любым
const user: User = await fetch("/api/me").then((r) => r.json());
user.name; // может упасть в рантайме, если name нет

Валидация через zod

Библиотека zod позволяет описать схему и получить из неё TypeScript-тип + проверить данные в рантайме:

import { z } from "zod";

const UserSchema = z.object({
    id: z.number(),
    name: z.string(),
    email: z.string().email(),
});

type User = z.infer<typeof UserSchema>; // тип выводится из схемы

async function fetchUser(): Promise<User> {
    const raw = await fetch("/api/me").then((r) => r.json());
    return UserSchema.parse(raw); // проверит и бросит ошибку, если не подходит
}

Это золотой стандарт работы с внешними данными в современных проектах.

Паттерн Result для async-операций

Вместо try/catch удобно возвращать тип-результат — успех или ошибку. Особенно полезно для обработки ошибок сети и валидации:

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

async function fetchUser(id: number): Promise<Result<User>> {
    try {
        const res = await fetch(`/api/users/${id}`);
        if (!res.ok) {
            return { ok: false, error: `HTTP ${res.status}` };
        }
        const value = await res.json();
        return { ok: true, value };
    } catch (e) {
        return { ok: false, error: e instanceof Error ? e.message : "unknown" };
    }
}

const result = await fetchUser(1);
if (result.ok) {
    console.log(result.value.name); // TS знает, что value есть
} else {
    console.log(result.error);
}

Типобезопасная конфигурация

Реальный приём — конфиг с проверкой во время запуска приложения. Так ошибка в переменной окружения ловится сразу, а не через час в продакшене:

import { z } from "zod";

const EnvSchema = z.object({
    DATABASE_URL: z.string().url(),
    PORT: z.coerce.number().default(3000),
    NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
});

type Env = z.infer<typeof EnvSchema>;

// Парсится один раз при старте — если что-то не так, приложение не запустится
const env: Env = EnvSchema.parse(process.env);

console.log(env.DATABASE_URL);
console.log(env.PORT); // точно number

Советы для крупных проектов

  • Включайте strict с первого дня — потом включать больно.
  • Избегайте any, используйте unknown для внешних данных.
  • Валидируйте данные на границе системы (API, localStorage, файлы) — zod или аналоги.
  • Не дублируйте типы — используйте Pick, Omit, Partial для производных типов.
  • Разделяйте DTO и доменные модели — то, что присылает клиент, не обязано совпадать с тем, что хранится в БД.
  • Дискриминируемые объединения для событий и состояний — это делает switch исчерпывающим и безопасным.
  • Project references для монорепозиториев и крупных проектов.
  • Запускайте tsc --noEmit в CI — отдельная проверка типов без сборки.

Типичные ошибки

1. as вместо валидации

// Плохо: говорим TypeScript "доверься"
const user = JSON.parse(raw) as User;

// Хорошо: проверяем схему
const user = UserSchema.parse(JSON.parse(raw));

2. Необработанный null/undefined

const el = document.querySelector(".btn");
el.addEventListener("click", () => {});
// ^^^ Ошибка: возможно, el — null

// Проверка
if (el) {
    el.addEventListener("click", () => {});
}

// Или сразу после запроса — assertion function
assertDefined(el);
el.addEventListener("click", () => {});

3. switch без default у дискриминируемого объединения

Если добавили новое значение в объединение и забыли обработать — TypeScript с подсветкой неисчерпывающего switch не даст этого пропустить (через never):

type Shape = { kind: "circle" } | { kind: "square" };

function area(s: Shape): number {
    switch (s.kind) {
        case "circle": return 1;
        case "square": return 2;
        default:
            // Если добавим { kind: "triangle" }, TS укажет здесь на ошибку
            const _exhaustive: never = s;
            return _exhaustive;
    }
}

Практика

  1. Опишите тип Status = "new" | "paid" | "done" и функцию, возвращающую описание для каждого значения.
  2. Создайте дискриминируемое объединение Notification (email, sms, push) и функцию отправки с switch.
  3. Реализуйте тип Result<T> и функцию-обёртку для fetch, возвращающую Result.
  4. Подключите zod и опишите схему для объекта пользователя; получите тип через z.infer.
  5. Используя Pick, Omit, Partial, постройте из интерфейса User три производных типа: для создания, обновления и публичного представления.

Итог курса

В финальной главе мы собрали вместе всё изученное: объединения и литеральные типы, сужение через typeof/instanceof/in, пользовательские type guards и assertion functions, utility-типы и паттерны для реальных проектов — DTO, валидация через zod, тип Result и типобезопасная конфигурация.

Поздравляем! Вы прошли базовый курс TypeScript. Дальше стоит:

  • Освоить декораторы (метаданные классов, фреймворки NestJS / TypeORM).
  • Изучить продвинутые типы: infer, mapped types, conditional types, template literal types.
  • Попробовать TypeScript в реальном фреймворке: React, Vue, NestJS.
  • Настроить ESLint с правилами для TypeScript и интегрировать в CI.

Главное — пишите код. TypeScript раскрывается полностью только в реальных проектах, когда типы начинают ловить ошибки, которые раньше отнимали часы отладки.

Комментарии 0

Для добавления комментариев необходимо войти или зарегистрироваться.

Пока нет комментариев. Станьте первым!