Система типов в 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 — это не про то, чтобы обвешать интерфейсами и типами каждую строчку ради галочки. Это про умение использовать правильные инструменты там, где они действительно нужны. Пишите код так, чтобы система типов работала на вас, а не вы на нее.
