Система типов в TypeScript может творить чудеса, но на практике многие используют ее едва ли на десять процентов. Признайтесь, мы все хотя бы раз лепили any просто чтобы компилятор отстал и дал собрать проект. Проблема в том, что такие компромиссы рано или поздно приводят к неожиданным падениям в рантайме.

В этой небольшой статье мы разберем с вами несколько полезных практик, которые помогут писать более чистый код и будут реально отлавливать баги еще до того, как они попадут в продакшен.

Просто забудьте про any

Когда вы вешаете на переменную тип any, вы буквально говорите компилятору: «Отключи проверки, я сам за все отвечаю». Это полностью убивает саму идею использования TypeScript. К тому же any имеет свойство расползаться по кодовой базе: если функция возвращает any, то и все переменные, куда вы запишете результат, станут нетипизированными.

Вместо этого используйте unknown. Это более строгий аналог any. В переменную типа unknown тоже можно положить что угодно, но TypeScript просто не даст вам вызвать у нее какие-либо методы или обратиться к свойствам, пока вы явно не проверите, что именно там лежит.

// Так делать не стоит: компилятор промолчит, а код упадет
const rawData: any = JSON.parse(userInput);
rawData.toLowerCase(); // Если там число, будет ошибка в рантайме

// Хороший подход: TypeScript заставляет сделать проверку
const safeData: unknown = JSON.parse(userInput);

// safeData.toLowerCase(); // Ошибка компиляции: Object is of type 'unknown'

if (typeof safeData === 'string') {
    console.log(safeData.toLowerCase()); // Теперь всё ок, TS знает, что это строка
}

Тип unknown идеально подходит для ответов от API, пользовательского ввода и блоков catch, где мы никогда не можем быть на 100% уверены в структуре данных.

Используйте satisfies вместо утверждения типа через as

Ключевое слово as — это жесткое приведение типов. Вы заставляете TypeScript поверить вам на слово. Если вы ошибетесь, зачастую компилятор не станет с вами спорить и в некоторых случаях может пропустить ошибку.

Еще в версии TypeScript 4.9 появился оператор satisfies. Он работает гораздо умнее: он проверяет, соответствует ли объект указанному типу, но при этом сохраняет узкий (выведенный) тип конкретных значений.

type Status = 'draft' | 'published' | 'archived';

type Article = {
    title: string;
    status: Status;
    tags: string[];
};

// Плохо: оператор 'as' скроет опечатку
const badArticle = {
    title: 'TS Tips',
    status: 'published',
    // Ой, мы забыли обязательное поле tags! Но TS промолчит из-за 'as'
} as Article;

// Отлично: 'satisfies' сразу укажет на ошибку
const safeArticle = {
    title: 'TS Tips',
    status: 'published',
} satisfies Article; // Ошибка: Property 'tags' is missing

Еще одна крайне полезная особенность satisfies проявляется, когда вы начинаете использовать ваш объект дальше по коду. Дело в том, что он сохраняет так называемые литеральные (узкие) типы.

Если мы объявляем переменную классическим способом, т. е. через двоеточие (const safeArticle: Article = ...), TypeScript применяет тип ко всему объекту целиком. Он забывает, что мы написали конкретный статус 'published', и отныне считает, что там может лежать любой вариант из нашего юниона ('draft' | 'published' | 'archived').

А вот satisfies работает тоньше: он только проверяет, что объект соответствует структуре Article, но при этом запоминает точные значения ключей.

type Status = 'draft' | 'published' | 'archived';

type Article = {
    title: string;
    status: Status;
};

// Классическая типизация
const articleA: Article = { title: 'TS', status: 'published' };
// Тип articleA.status расширился до Status.
// TS думает, что там может быть и 'draft', и 'archived'

// Использование satisfies
const articleB = { title: 'TS', status: 'published' } satisfies Article;
// Тип articleB.status остался строго 'published'!

Что это дает на практике? Полезный автокомплит и страховку от лишних проверок. Если вы позже в коде напишете if (articleB.status === 'draft'), компилятор тут же подсветит это место и скажет: «Эй, это условие никогда не выполнится, я же помню, что там лежит строка "published"!». Вы получаете и строгую проверку контракта интерфейса, и максимально точные типы конкретных значений.

Пишем собственные тайпгарды с помощью is

Стандартных проверок через typeof или instanceof часто не хватает, особенно когда мы работаем со сложными интерфейсами. В таких случаях можно писать свои функции-предикаты (type guards), которые будут подсказывать компилятору тип данных.

Если функция возвращает obj is MyType, это означает: «в случае возврата true гарантируется, что переданный аргумент имеет тип MyType».

interface PaymentSuccess {
    status: 'success';
    transactionId: string;
}

interface PaymentFailed {
    status: 'error';
    errorMessage: string;
}

type PaymentResult = PaymentSuccess | PaymentFailed;

// Наш кастомный тайпгард
function isSuccess(result: PaymentResult): result is PaymentSuccess {
    return result.status === 'success';
}

function handlePayment(result: PaymentResult) {
    if (isSuccess(result)) {
        // Здесь TS точно знает, что это PaymentSuccess
        console.log(`Успешно! ID: ${result.transactionId}`);
    } else {
        // А здесь TS понимает, что остался только PaymentFailed
        console.error(`Ошибка: ${result.errorMessage}`);
    }
}

Особенно классно тайпгарды работают в связке с методами массивов. Например, конструкция results.filter(isSuccess) автоматически вернет массив с правильным типом PaymentSuccess[].

Отказываемся от Enum в пользу Union типов

Начинающие разработчики часто обожают enum. Кажется, что это очень удобный и логичный способ перечислить константы, особенно если вы пришли из других языков, таких как C# или Java. Но в TypeScript у enum есть довольно неприятная изнанка.

Дело в том, что главная философия TypeScript звучит так: «типы существуют только во время разработки». При сборке проекта компилятор просто стирает все интерфейсы и типы, оставляя чистый, легкий JavaScript. Но enum нарушает это правило. Он превращается в реальный JavaScript-код.

Если вы напишете вот такой безобидный enum:

enum Role {
    Admin,
    Editor
}

После компиляции в ваш итоговый бандл попадет такой странный кусок кода:

var Role;
(function (Role) {
    Role[Role["Admin"] = 0] = "Admin";
    Role[Role["Editor"] = 1] = "Editor";
})(Role || (Role = {}));

Это утяжеляет приложение. К тому же, обычные числовые enum в TypeScript исторически вели себя странно: компилятор позволял записать в переменную с типом Role вообще любую цифру (например, 99), даже если такого варианта в перечислении нет, и не выдавал ошибку.

Что делать вместо этого? Используйте Union-типы (объединения строк). Они дают ровно ту же автоподстановку в редакторе, ту же проверку на опечатки, но при этом вообще ничего не весят в готовом JS-файле.

// Просто, понятно, безопасно и 0 байт в итоговом JS-файле
type UserRole = 'admin' | 'editor' | 'viewer';

function setRole(role: UserRole) {
    // ...
}

setRole('admin'); // Редактор сам подскажет варианты
// setRole('owner'); // Ошибка: такого варианта нет

А что, если нужно вывести все роли в выпадающем списке? Юнион ведь нельзя перебрать в цикле. В таком случае просто создаем обычный JavaScript-объект и «замораживаем» его магической конструкцией as const. Так вы получите и настоящий объект для циклов, и строгие типы для компилятора:

const ROLES = {
    Admin: 'admin',
    Editor: 'editor',
    Viewer: 'viewer'
} as const;

// Маги�� TS: вытягиваем типы прямо из объекта
// Получится тот самый юнион: 'admin' | 'editor' | 'viewer'
type AppRole = typeof ROLES[keyof typeof ROLES];

// И теперь мы можем спокойно использовать Object.values(ROLES) в циклах

Правильно типизируем словари через Record

Часто можно встретить код, где объекты типизируют как Object или {}. Проблема в том, что Object пропустит практически всё (включая примитивы), а {} означает просто «любое значение, кроме null и undefined».

Для описания структур в виде «ключ-значение» лучше всегда использовать встроенный утилитный тип Record.

// Непонятно, какие ключи и значения тут лежат
const badConfig: {} = { url: 'localhost' };

// Четкий контракт: ключи — строки, значения — неизвестны до проверки
type Config = Record<string, unknown>;

const goodConfig: Config = {
    url: 'localhost',
    port: 8080
};

Record раскрывает весь свой потенциал, когда мы ограничиваем возможные ключи. Например, если у нас есть набор ролей, мы можем гарантировать, что для каждой роли описаны права доступа:

type Role = 'admin' | 'user' | 'guest';

// Если забудем какую-то роль или добавим лишнюю, TS будет ругаться
const permissions: Record<Role, string[]> = {
    admin: ['read', 'write', 'delete'],
    user: ['read', 'write'],
    guest: ['read']
};

Если нужно, чтобы ключи были необязательными, просто оберните Record в Partial.

Генерируем типы с помощью шаблонных литералов

В TypeScript есть мощный инструмент для работы со строками — шаблонные литералы (Template Literal Types). С их помощью можно динамически собирать новые типы из существующих юнионов. Компилятор сам перемножит возможные варианты и создаст все нужные комбинации.

Это очень полезно при описании дизайн-систем, роутинга или названий событий.

type ButtonSize = 'small' | 'medium' | 'large';
type ButtonTheme = 'primary' | 'secondary';

// TS сгенерирует 6 возможных вариантов
type ButtonClass = `btn-${ButtonSize}-${ButtonTheme}`;
// 'btn-small-primary' | 'btn-small-secondary' | 'btn-medium-primary' ... и т.д.

const myClass: ButtonClass = 'btn-medium-primary'; // Ок
// const wrongClass: ButtonClass = 'btn-huge-primary'; // Ошибка

Также в TS есть встроенные типы для трансформации строк: Capitalize, Lowercase, Uppercase. С их помощью можно, например, изящно типизировать обработчики событий:

type EventType = 'click' | 'hover' | 'scroll';
// Автоматически получаем 'onClick' | 'onHover' | 'onScroll'
type EventHandler = `on${Capitalize<EventType>}`;

Подводя итог

Написание хорошего кода на TypeScript — это не про то, чтобы обвешать интерфейсами и типами каждую строчку ради галочки. Это про умение использовать правильные инструменты там, где они действительно нужны. Пишите код так, чтобы система типов работала на вас, а не вы на нее.