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

little-brother
25.02.2026 06:59Признайтесь, мы все хотя бы раз лепили
anyпросто чтобы компилятор отсталЯ не писал ни разу, у меня линтер не пропускает такое по умолчанию. Но ts не учил, типы пишет ИИ.

Alexandroppolus
25.02.2026 06:59Пример для параграфа о собственных тайпгардах не очень удачный - там discriminated union и всё работает "из коробки":
if (result.status === 'success') { // Здесь TS точно знает, что это PaymentSuccess console.log(`Успешно! ID: ${result.transactionId}`); } else { // А здесь TS понимает, что остался только PaymentFailed console.error(`Ошибка: ${result.errorMessage}`); }причем даже для filter:
results.filter(r => r.status === 'success')- массив PaymentSuccess, тут ts сам увидел тайпгард

frostsumonner
25.02.2026 06:59Про enum не согласен. Семантически это реально должен быть enum, а не union строк. То что он в итоговом коде неоч. выглядит можно пережить. Во всяком случае уход от enum код не улучшает никак.
markelov69
Ну как бы нет) Ошибку он не пропустит, просто она не так красиво будет подсвечена
kanasero Автор
Спасибо за внимательность! Абсолютно верное замечание. Исправление в статью внесено. Корректный пример должен выглядеть вот так (с иcпользованием
asошибка пропускается, а приsatisfiesнет):felldek
С ошибкой вы верно подметили, но зачастую пишут 'as unknown as Type'
Все равно satisfies возвращает более узкий тип и в этом его преимущество