Я часто слышу от своих коллег, что TypeScript для них — как заноза в заднице. В каждом проекте они вынуждены писать полотна типов, TypeScript постоянно бьёт по рукам и не компилирует сборку, пока очередной метод не будет типизирован с головы до пят.
Когда я начинал работать с TypeScript, мне это очень нравилось: было весело описывать типы, а хорошо типизированные структуры становились отличной документацией. Однако со временем меня это утомило. Я начал злиться каждый раз, когда не мог ступить и шагу без строгой типизации всего подряд.
После этого мне пришлось взглянуть на ситуацию с другой стороны. Полистав документацию по TypeScript и проанализировав собственный дискомфорт, а также переживания коллег, я решил написать эту статью о лучших практиках типизации:
Использование строгих типов для межсервисных взаимодействий
Рекомендация: Используйте типы не только в клиентском коде, но и в API-контрактах, таких как REST и GraphQL. Типизируйте всё, что приходит с сервера и отправляется обратно.
Кейс: Сервер присылает данные о пользователе, которые мы уже описали в соответствии с контрактом. Это позволяет нам точно понимать, чего ожидать от сервера и как работать с его данными. Логика приложения гарантированно соответствует контракту, а если структура ответа изменится, мы быстро обнаружим проблему и сможем её исправить.
Пример:
// Тип данных, который передает API
export interface UserData {
id: string;
name: string;
email: string;
}
// Тип для ответа с сервера
export interface ApiResponse<T> {
data: T;
error: string | null;
}
Сonst assertions
Рекомендация: Используйте
as constдля работы с массивами и объектами, если значения в них не изменяются. Это помогает TypeScript правильно типизировать такие данные.Кейс: В крупном проекте по управлению правами пользователей используется массив разрешений. Благодаря
as const, который явно указывает, что значения в массиве фиксированы, мы можем точно определить типы этих разрешений и избежать ошибок при присвоении.
Пример:
const roles = ['admin', 'user', 'guest'] as const;
type Role = typeof roles[number]; // "admin" | "user" | "guest"
Использование типов в тестах и моках для улучшения покрытия
Рекомендация: Типизируйте мок-объекты и ответы на запросы, чтобы тесты были не только актуальными, но и стабильными.
Кейс: В проекте для управления заказами моки используются для имитации ответов сервера в юнит-тестах. Благодаря типизации этих моков тесты становятся более надёжными, так как проверяется не только структура возвращаемых данных, но и соответствие типам, что помогает предотвратить ошибки на ранних этапах разработки.
Пример:
// Мок для API-запроса
const mockApiResponse: ApiResponse<UserData> = {
data: { id: "123", name: "John Doe", email: "john.doe@example.com" },
error: null
};
Использование шаблонных строк и литеральных типов для улучшения читаемости
Рекомендация: Используйте литеральные типы и шаблонные строки для создания более предсказуемых типов данных в местах, где требуется точная валидация строк.
Кейс: В системе обработки заказов литеральные типы могут быть использованы для указания статуса заказа (например, "pending", "shipped", "delivered"). Это гарантирует, что статус будет всегда валиден, и предотвращает ошибочные строки вроде "shippeded" или "shippd".
Пример:
type QueryMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
function sendRequest(method: QueryMethod, url: string) {
console.log(`${method} request to ${url}`);
}
Декораторы и метаданные для продвинутых паттернов проектирования
Декораторы и метаданные позволяют создавать более элегантные и модульные решения, особенно если речь идет о фреймворках и паттернах, таких как DI (dependency injection) и AOP (aspect-oriented programming).
Рекомендация: Используйте декораторы для реализации дополнительных функциональностей в объектах или классах без нарушения принципа SOLID.
Кейс: При нажатии на кнопку "Сохранить" данные отправляются на сервер. Однако нам также необходимо сохранить данные в лог. Если мы добавим логирование в метод отправки данных, это нарушит принцип единственной ответственности SRP (Single Responsibility Principle). Вместо этого следует использовать декоратор, который изолирует логику логирования, сохраняя чистоту исходного метода.
Пример:
function logMethod(target: any, propertyName: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function(...args: any[]) {
console.log(`Method ${propertyName} called with args: ${args}`);
return originalMethod.apply(this, args);
};
}
class UserService {
@logMethod
fetchUserData(id: number) {
return `User data for ${id}`;
}
}
Использование дженериков для повышения гибкости и типобезопасности
Дженерики позволяют создавать универсальные функции и классы, которые могут работать с различными типами данных, при этом сохраняя типобезопасность.
Рекомендация: Используйте дженерики для работы с разными типами данных в одной функции или классе, не теряя типизации.
Кейс: Когда нужно, чтобы метод работал с разными типами данных, используют дженерики. Мы передаём нужный тип, и метод принимает его, оставаясь универсальным и безопасным.
Пример:
function identity<T>(value: T): T {
return value;
}
const stringValue = identity("Hello, world!"); // string
const numberValue = identity(42); // number
Дженерики полезны, например, при работе с коллекциями или утилитами, где необходимо работать с различными типами данных, но при этом обеспечивать консистентность типов.
Использование утилитарных типов для сокращения шаблонного кода
Рекомендация: Используйте утилитарные типы, такие как
Partial,Readonly,Record,Pick,Excludeи другие, чтобы сокращать повторяющийся код и повысить читаемость.Кейс: В проекте по управлению пользователями утилитарный тип
Partialиспользуется для обновления только отдельных полей пользователя, не меняя всю структуру данных. Это позволяет работать с объектами, где обновляются только нужные свойства, что уменьшает вероятность ошибок и упрощает логику обновлений.
Пример:
interface User {
id: number;
name: string;
email: string;
}
type UserWithPartialName = Partial<User>; // Все свойства могут быть неопределёнными
const user: UserWithPartialName = { id: 1 }; // Валидно, т.к. name и email не обязательны
type UserWithoutEmail = Omit<User, 'email'>; // Исключаем email
const user2: UserWithoutEmail = { id: 2, name: 'John Doe' };
Типизация замыкания и функций высшего порядка
Когда вы работаете с функциями, которые принимают другие функции в качестве аргументов или возвращают их, правильная типизация помогает избежать неочевидных ошибок.
Рекомендация: Явно типизируйте функции и их параметры в таких случаях, чтобы TypeScript мог правильно проверять корректность их использования.
Кейс: В проекте для кэширования данных используется функция высшего порядка, которая оборачивает исходные функции, чтобы запоминать результаты выполнения. Явная типизация помогает удостовериться, что типы аргументов и возвращаемых значений сохраняются правильными при каждой функции, которая проходит через кэширование.
Пример:
function memoize<A extends unknown[], R>(fn: (...args: A) => R): (...args: A) => R {
const cache = new Map<string, R>();
return (...args) => {
const key = JSON.stringify(args);
if (cache.has(key)) {
return cache.get(key)!;
}
const result = fn(...args);
cache.set(key, result);
return result;
};
}
Использование шаблонных типов и conditional types
Рекомендация: Используйте условные типы и шаблонные типы для реализации гибкой и мощной типизации в приложении.
Кейс: В проекте с заказами тип меняется в зависимости от состояния заказа. Если заказ оплачен, тип включает информацию о платеже, а если он в обработке — о доставке. С помощью conditional types можно менять типы в зависимости от того, что происходит с заказом. А шаблонные типы позволяют создавать универсальные структуры, например, для разных способов оплаты, где каждый требует свои параметры.
Пример:
type OrderStatus = 'pending' | 'paid' | 'shipped';
type Order<T extends OrderStatus> = T extends 'paid'
? { amount: number; paymentDate: Date }
: T extends 'shipped'
? { shippingDate: Date }
: {};
const paidOrder: Order<'paid'> = { amount: 100, paymentDate: new Date() };
const shippedOrder: Order<'shipped'> = { shippingDate: new Date() };
Использование readonly и неизменяемых структур
Рекомендация: Массивы и объекты, которые не требуют изменений, должны быть объявлены как
readonly.Кейс: В проекте для обработки заказов, где заказ уже отправлен и его нельзя изменить, используется тип
readonly. Это помогает предотвратить любые изменения в данных заказа после отправки на обработку, что сохраняет целостность данных и избегает ошибок на разных этапах..
Пример:
const user: Readonly<UserData> = {
id: "123",
name: "John",
email: "john.doe@example.com"
};
// Ошибка: нельзя изменить свойство в readonly объекте
user.name = "Doe";
Заключение
TypeScript может быть утомительным, особенно когда нужно следить за типами в каждом методе и переменной. В этой статье я поделился своим опытом и лучшими практиками, которые помогают найти баланс между типобезопасностью и удобством разработки.
Важно помнить, что эффективность TypeScript зависит не только от его возможностей, но и от того, как вы подходите к решению задач в вашем проекте. Строгая типизация и продуманная структура типов — это основа качественного кода, но они не заменят хорошую архитектуру и подход к проектированию.
UPD: Статья была обновлена. Я изменил стиль подачи и убрал ненужные детали. При этом сами практики и примеры остались прежними.
Комментарии (32)

serginho
15.11.2024 22:20Добавлю, что для охраны границы между неизвестными данными и строгой типизацией для TypeScript уже де-факто стандартом являются библиотеки
class-transformerиclass-validator

ganqqwerty
15.11.2024 22:20Почему-то чувствую, что пообщался с чатгпт.

artptr86
15.11.2024 22:20У автора во всех статьях чатгпт чувствуется.

DmitryR3989 Автор
15.11.2024 22:20Статью я писал сам, просто не определился с подачей. С одной стороны хочется писать более официально и структурировано, а с другой максимально по простому. Пока прощупываю аудиторию). Сорян, всего неделю на хабре

Avangardio
15.11.2024 22:20Про перегрузки хотелось бы услышать, почему и зачем они нужно, какой водопад из каких условий строить и все такое, потому что тема очень важная

tertiumnon
15.11.2024 22:20Больно смотреть на нейминг типа UserData, logMethod - подумайте над тем, действительно ли вам нужны эти бесполезные приставки?

DmitryR3989 Автор
15.11.2024 22:20Примеры несут лишь иллюстративный характер. Не рекомендуется использовать в реальном коде.

dom1n1k
15.11.2024 22:20Чето пример с заказами я не понимаю. Если заказ shipped, у него исчезает paymentDate?

DmitryR3989 Автор
15.11.2024 22:20Да, тип shippedOrder будет иметь только
shippingDate: Date.Согласно условиям для дженериков получается следующая картина:
Order<'paid'> = paidOrderC полямиamount: numberиpaymentDate: DateOrder<'shipped'> = shippedOrderC полямиshippingDate: DateТеперь мы используем OrderStatus для определения типа Order. Один тип имеет сумму и дату оплаты, а другой дату отправки. В итоге получился динамический тип который изменяется относительно статуса заказа

dom1n1k
15.11.2024 22:20Но это же противоречит бизнес-логике, не?

DmitryR3989 Автор
15.11.2024 22:20Пример несет за собой цель показать как работают условные и литеральные типы. Данный пример не несет за собой цель показать как работать с заказами.
Всего лишь показываю, как работают типы. А то как Вы будете реализовывать бизнес-логику с condition types и литералами это уже не мое дело

donatello2005
15.11.2024 22:20Я бы ещё рекомендовал библиотеку Zod использовать для валидации входных (и выходных, если есть желание) данных API-сервера, которая свои схемы валидации переводит в TS типы на лету без всяких компиляций. Очень удобно, когда ты уверен, что валидация и входящие в роут данные (params, body, query) идентичны.

Alexandroppolus
15.11.2024 22:20Согласен со всеми поинтами из статьи, но слегка докопаюсь к примерам.
1) Функция
memoizeоформлена не совсем правильно, теряются типы аргументов. Я знаю два способа:Вариант1 и вариант2
function memoize1<A extends unknown[], R>(fn: (...args: A) => R): (...args: A) => R { const cache = new Map<string, R>(); return (...args) => { const key = JSON.stringify(args); if (cache.has(key)) { return cache.get(key)!; } const result = fn(...args); cache.set(key, result); return result; }; } function memoize2<F extends (...args: never[]) => unknown>(fn: F): F { const cache = new Map<string, unknown>(); return ((...args) => { const key = JSON.stringify(args); if (cache.has(key)) { return cache.get(key)!; } const result = fn(...args); cache.set(key, result); return result; }) as F; }Второй вариант выглядит чуть более криво из-за ручного приведения типов (
as Fи т.д.), зато поддерживает перегрузку функций (пример, см. типы для mf1 и mf2)2) Пример с шаблонными и условными типами довольно странный. Вместо одного
Order<T>для трех случаев, проще сделать отдельные типы OrderPaid и OrderShipped, со своими полями. А вообще здесь используют discriminated unions, чтобы потом в рантайме легко определить, какое у нас значение:Пример
type Order = { status: 'pending'; } | { status: 'paid'; amount: number; paymentDate: Date; } | { status: 'shipped'; shippingDate: Date; }; type OrderStatus = Order['status']; function f(order: Order) { if (order.status === 'paid') { // доступны order.amount и order.paymentDate } else {...} }
DmitryR3989 Автор
15.11.2024 22:20Спасибо, взял на вооружение). Пример с замыканиями действительно не валиден, так как я по невнимательности воткнул туда
any. Ваш пример сunknownмне нравится больше. Я добавлю его в статью как пример. Приводить примеры не моя сильная сторона).

meonsou
15.11.2024 22:20Кейс: В проекте для кэширования данных используется функция высшего порядка, которая оборачивает исходные функции, чтобы запоминать результаты выполнения. Явная типизация помогает удостовериться, что типы аргументов и возвращаемых значений сохраняются правильными при каждой функции, которая проходит через кэширование.
Вы сами то хоть читали свои примеры? Где там сохраняются типы аргументов?

adminNiochen
15.11.2024 22:20Про производительность и const assertions ваще не понял. Автор пытается сказать что программистам нужно думать о скорости транспиляции ts в js?

DmitryR3989 Автор
15.11.2024 22:20Ну не совсем. Речь идет о производительности проверки типов. Приложение не станет быстрее, но упрощается проверка типов на уровне TypeScript. Это влияет на процессы типа проверки типов в IDE, линтинг, пайплайны CI/CD и даже компиляция в JavaScript. В небольших проектах это не так заметно, но в больших, где много типизации, такая оптимизация может дать свои плоды. Мелочь, а приятно!

meonsou
15.11.2024 22:20Откуда информация о влиянии as const на производительность тайпчекера? Есть ссылки или бенчмарки?

Solant
15.11.2024 22:20Тут вы начали придумывать функционал из головы: const assertion никак не связан с производительностью
as const убирает type widening и тип выражения начинает работать как литерал/readonly тюпл
https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-4.html#const-assertions

aleksand44
15.11.2024 22:20Я начал злиться каждый раз, когда не мог ступить и шагу без строгой типизации всего подряд.
TS - это unsound язык, который и так допускает тысячи поблажек из-за чего толку в его типизации и не так уж и много, документирование по большей части. Но о гарантии отсутствия ошибок в программе на момент компиляции - главный признак sound языков говорить не приходится. Если для Вас тайпскрипт заноза в заднице, так используйте JS. И помните, что есть ещё всякие ReScript, Elm и прочие языки которые гораздо строже тайпскрипта.

Voznov
15.11.2024 22:20Во-первых, хочу поблагодарить за статью: она действительно будет полезна новичкам в TS. Быть может даже возьму её на вооружение для новоприбывших джунов
Во-вторых, вставлю свои 5 копеек, которые новичкам будет полезно знать:Если вы собираетесь писать декораторы, то используйте встроенную типизацию от TS (ClassDecorator, PropertyDecorator, MethodDecorator, ParameterDecorator). А также пишите лучше генераторы декоратов (по-простому: функции, которые возвращают декораторы), т.к. каждый декоратор рано или поздно захочется параметризовать, а также это банально стандарт в мире декораторов (загляните в любую серьёзную библиотеку, чтобы в этом убедиться). Пример с кодом автора:
const logMethod = (): MethodDecorator => (target, propertyName, descriptor: PropertyDescriptor) => { const originalMethod = descriptor.value; descriptor.value = function(...args: unknown[]) { console.log(`Method ${String(propertyName)} called with args: ${args}`); return originalMethod.apply(this, args); }; } class UserService { @logMethod() fetchUserData(id: number) { return `User data for ${id}`; } }Рекомендация: Используйте условные типы и шаблонные типы для реализации гибкой и мощной типизации в приложении.
Условные типы хороши для работы внутри дженериков в первую очередь. Вместо примера от автора всегда лучше использовать вариант с union + key field (в примере поле
status):type PaidOrder = { status: 'paid', amount: number; paymentDate: Date } type ShippedOrder = { status: 'shipped', shippingDate: Date } type PendingOrder = { status: 'pending' } type Order = PaidOrder | ShippedOrder | PendingOrder; type OrderStatus = Order['status']; const paidOrder: PaidOrder = { status: 'paid', amount: 100, paymentDate: new Date() }; const shippedOrder: ShippedOrder = { status: 'shipped', shippingDate: new Date() };Во-первых он будет читаться проще. Во-вторых вы всегда сможете понять в рантайме, какая сущность перед вами и использовать это. Пример:
const getTheBestColorForOrder = (order: Order): string => order.type === 'paid' ? '#abc000' : order.type === 'shipped' ? '#fffddd' : '#eeeeee'Также новичкам посоветую по возможности избегать
anyи использовать вместо негоunknown. Да, вы не сможете избежать его полностью, особенно когда будете писать сложные дженерики, но просто надо понять, чтоanyравносильно тому, что вы пишите на JS. Потому для проектов, которые переезжают с JS на TS это может быть допустимо, когда нет возможности тратить время на полный переезд сейчас, но в новых проектах старайтесь, чтобы каждое использованиеanyбыло оправдано, не забудьте в обязательном порядке написать комментарий в духе// FIXME мое-объяснение-причины-появления-anyВсем добра; надеюсь, никого не обидел

Alexandroppolus
15.11.2024 22:20посоветую по возможности избегать
anyи использовать вместо негоunknown. Да, вы не сможете избежать его полностью, особенно когда будете писать сложные дженерики ...В одиночку, разумеется,
unknownне может полностью заменитьany, но в связке сnever- запросто.

isumix
15.11.2024 22:20Зачем в заголовке "производительность"?
Typescript это про проверку типов до запуска приложения, следственно он к производительности не имеет никакого отношения.
vasille
A вы пробовали flow.js? Интересно используют ли его где ни будь кроме как в Мета (они авторы).
DmitryR3989 Автор
Нет, не пробовал. Но мне кажется, это полумера
k12th
Одно время Flow соперничал с TS — в частности, из-за более быстрой транспиляции, нативной поддержки в React, и изначально более строгому подходу к типизации, но, кажется, давно проиграл эту гонку.