TypeScript с нуля · Глава 3 из 8

Глава 3. Объектные типы, интерфейсы и type aliases

Прогресс сохранится в этом браузере (войдите, чтобы синхронизировать).

Объектный тип

Форму объекта можно описать прямо в аннотации:

const user: { name: string; age: number } = {
    name: "Анна",
    age: 28,
};

Для разового использования это нормально, но при повторах удобнее вынести описание в отдельный тип.

Интерфейсы

interface — именованное описание формы объекта:

interface User {
    id: number;
    name: string;
    age: number;
    email?: string;          // необязательное поле (может отсутствовать)
    readonly createdAt: string; // нельзя менять после создания
}

const user: User = {
    id: 1,
    name: "Анна",
    age: 28,
    createdAt: "2024-01-01",
};

// user.createdAt = "2025-01-01"; // Ошибка: readonly
console.log(user.email);           // string | undefined

Необязательные свойства

Знак ? после имени поля означает, что оно может отсутствовать. TypeScript добавляет | undefined к его типу:

interface Article {
    title: string;
    subtitle?: string; // string | undefined
}

const a: Article = { title: "Привет" }; // OK, subtitle можно не указывать

readonly

Защищает от переназначения на уровне компилятора (в рантайме защиты нет, это не Object.freeze):

interface Point {
    readonly x: number;
    readonly y: number;
}

const p: Point = { x: 10, y: 20 };
// p.x = 30; // Ошибка

type alias

type создаёт псевдоним типа. Для объектов синтаксис похож на интерфейс:

type Point = {
    x: number;
    y: number;
};

const p: Point = { x: 10, y: 20 };

Но type гибче — описывает не только объекты:

// Объединение строк
type Status = "new" | "paid" | "done";

// Псевдоним для примитива
type UserId = number;

// Функциональный тип
type Callback = (value: string) => void;

// Кортеж
type Pair = [string, number];

interface vs type — что выбрать

Для описания объектов и классов чаще берут interface, для объединений, кортежей и функциональных типов — type. На практике оба взаимозаменяемы для объектов.

Возможностьinterfacetype
Объекты
Объединения (A | B)
Кортежи
Расширениеextendsпересечение &
Декларация-слияние✓ (один интерфейс можно объявить дважды)

Расширение интерфейсов

Один интерфейс может расширять другие — это удобный способ строить иерархии:

interface Animal {
    name: string;
    age: number;
}

interface Pet extends Animal {
    owner: string;
}

interface WildAnimal extends Animal {
    habitat: string;
}

const cat: Pet = { name: "Мурка", age: 4, owner: "Анна" };
const wolf: WildAnimal = { name: "Серый", age: 6, habitat: "лес" };

Пересечение через type и &:

type WithTimestamps = { createdAt: string; updatedAt: string };
type User = { id: number; name: string };

type UserWithTimestamps = User & WithTimestamps;

Индексные сигнатуры

Когда ключи заранее неизвестны — например, словарь или кэш:

interface Cache {
    [key: string]: string;
}

const cache: Cache = {};
cache["user:1"] = "Анна";
cache["user:2"] = "Борис";

// Смешанный объект: фиксированные поля + произвольные
interface ApiResponse {
    status: number;
    data: unknown;
    [key: string]: unknown; // любые дополнительные поля
}

Record вместо индексной сигнатуры

Часто удобнее использовать утилиту Record<K, V>:

type UsersById = Record<number, string>;

const users: UsersById = {
    1: "Анна",
    2: "Борис",
};

Readonly-объекты целиком

Чтобы сделать все свойства объекта неизменяемыми, используют Readonly<T> или readonly в типе:

interface Config {
    apiUrl: string;
    timeout: number;
}

const config: Readonly<Config> = {
    apiUrl: "https://api.example.com",
    timeout: 5000,
};

// config.timeout = 1000; // Ошибка: readonly

Кейс из реального проекта: модель пользователя

Типичный паттерн — отдельные интерфейсы для «создания» и «в БД»:

// Что присылает фронтенд при регистрации
interface CreateUserDTO {
    name: string;
    email: string;
    password: string;
}

// Что хранится в базе (id и timestamps проставляет сервер)
interface User extends CreateUserDTO {
    id: number;
    createdAt: string;
    updatedAt: string;
}

// Что отдаём наружу по API (без пароля)
interface UserPublic {
    id: number;
    name: string;
    email: string;
}

function createUser(dto: CreateUserDTO): User {
    return {
        ...dto,
        id: nextId(),
        createdAt: now(),
        updatedAt: now(),
    };
}

function toPublic(user: User): UserPublic {
    return { id: user.id, name: user.name, email: user.email };
}

Кейс: дискриминируемые объединения

Когда объект может принимать несколько форм, добавляют общее поле-признак. Это мощный приём для обработки событий и ошибок:

type ApiResult =
    | { status: "success"; data: User }
    | { status: "error"; message: string };

function handle(result: ApiResult): void {
    if (result.status === "success") {
        console.log(result.data.name); // TS знает, что data есть
    } else {
        console.log(result.message);   // TS знает, что message есть
    }
}

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

1. Забыли, что необязательное поле — это | undefined

interface User {
    email?: string;
}

const u: User = {};
console.log(u.email.length);
//          ^^^^^^^^
// Ошибка: возможно, email — undefined

Нужно проверить или использовать ?.:

console.log(u.email?.length);

2. Изменение readonly в рантайме «в обход»

const config: Readonly<Config> = { apiUrl: "x", timeout: 1 };
(config as any).timeout = 2; // скомпилируется, но это потеря защиты

Не используйте as any, чтобы обходить ограничения — это обнуляет смысл TypeScript.

3. Лишние поля в объекте

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

const u: User = { id: 1, name: "Анна", age: 28 };
//                                       ^^^
// Ошибка: "age" не существует в типе User

Итог

Мы научились описывать форму объектов через interface и type, использовать необязательные и readonly-поля, индексные сигнатуры и расширение интерфейсов. Разобрали реальный паттерн с DTO и публичной моделью, а также мощный приём — дискриминируемые объединения. В следующей главе — типизация функций.

Практика

Решено 0 из 3

Ответ на каждое задание можно получить, только написав и запустив программу. Глава засчитывается, когда решены все задания.

  1. Дан интерфейс и массив товаров:

    interface Product {
        id: number;
        title: string;
        price: number;
        inStock: boolean;
    }
    
    const products: Product[] = [
        { id: 11, title: "Клавиатура", price: 4590, inStock: true },
        { id: 12, title: "Мышь", price: 1290, inStock: false },
        { id: 13, title: "Монитор", price: 24900, inStock: true },
        { id: 14, title: "Наушники", price: 7800, inStock: true },
        { id: 15, title: "Коврик", price: 690, inStock: false },
    ];

    Для товаров, которые есть в наличии, посчитайте сумму price * id. Введите результат.

  2. Для того же массива товаров отсортируйте его по убыванию цены и введите названия трёх самых дорогих товаров через запятую (порядок не важен).

  3. Для того же массива товаров посчитайте сумму произведений цены на длину названия (price * title.length). Введите результат.

Ответы проверяются на сервере, а решённые задания сохраняются в этом браузере. Войдите, чтобы прогресс синхронизировался между устройствами.

Содержание серии (8)