Лучшие практики TypeScript: Строгая типизация, гибкость и производительность
- воскресенье, 17 ноября 2024 г. в 00:00:02
Я часто слышу от своих коллег, что 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;
}
Рекомендация: Используйте as const
для работы с массивами и объектами, если значения в них не изменяются. Это помогает TypeScript правильно типизировать такие данные и улучшает производительность.
Кейс: В крупном проекте по управлению правами пользователей используется массив разрешений. Благодаря as const
, который явно указывает, что значения в массиве фиксированы, мы можем точно определить типы этих разрешений, избежать ошибок при присвоении и повысить производительность, так как TypeScript будет уверен в неизменности этих значений.
Пример:
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<T>(fn: (...args: any[]) => T): (...args: any[]) => T {
const cache = new Map<string, T>();
return (...args: any[]) => {
const key = JSON.stringify(args);
if (cache.has(key)) {
return cache.get(key) as T;
}
const result = fn(...args);
cache.set(key, result);
return result;
};
}
Рекомендация: Используйте условные типы и шаблонные типы для реализации гибкой и мощной типизации в приложении.
Кейс: В проекте с заказами тип меняется в зависимости от состояния заказа. Если заказ оплачен, тип включает информацию о платеже, а если он в обработке — о доставке. С помощью 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
. Это помогает предотвратить любые изменения в данных заказа после отправки на обработку, что сохраняет целостность данных и избегает ошибок на разных этапах..
Пример:
const user: Readonly<UserData> = {
id: "123",
name: "John",
email: "john.doe@example.com"
};
// Ошибка: нельзя изменить свойство в readonly объекте
user.name = "Doe";
TypeScript может быть утомительным, особенно когда нужно следить за типами в каждом методе и переменной. В этой статье я поделился своим опытом и лучшими практиками, которые помогают найти баланс между типобезопасностью и удобством разработки.
Важно помнить, что эффективность TypeScript зависит не только от его возможностей, но и от того, как вы подходите к решению задач в вашем проекте. Строгая типизация и продуманная структура типов — это основа качественного кода, но они не заменят хорошую архитектуру и подход к проектированию.
UPD: Статья была обновлена. Я изменил стиль подачи и убрал ненужные детали. При этом сами практики и примеры остались прежними.