Я часто слышу от своих коллег, что 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)


  1. vasille
    15.11.2024 22:20

    A вы пробовали flow.js? Интересно используют ли его где ни будь кроме как в Мета (они авторы).


    1. DmitryR3989 Автор
      15.11.2024 22:20

      Нет, не пробовал. Но мне кажется, это полумера


    1. k12th
      15.11.2024 22:20

      Одно время Flow соперничал с TS — в частности, из-за более быстрой транспиляции, нативной поддержки в React, и изначально более строгому подходу к типизации, но, кажется, давно проиграл эту гонку.


  1. serginho
    15.11.2024 22:20

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


    1. Avangardio
      15.11.2024 22:20

      Согласен, крутые библиотеки


    1. meonsou
      15.11.2024 22:20

      Про стандарт загнули конечно, на моей памяти ими только любители неста пользуются. На фронте стандарт это зод, а так используют кучу всего.


      1. Splicerok
        15.11.2024 22:20

        Не, стандарт все ещё yup, но zod пошустрее работает и выглядит покруче


  1. ganqqwerty
    15.11.2024 22:20

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


    1. artptr86
      15.11.2024 22:20

      У автора во всех статьях чатгпт чувствуется.


      1. DmitryR3989 Автор
        15.11.2024 22:20

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


  1. Avangardio
    15.11.2024 22:20

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


  1. tertiumnon
    15.11.2024 22:20

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


    1. DmitryR3989 Автор
      15.11.2024 22:20

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


  1. dom1n1k
    15.11.2024 22:20

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


    1. DmitryR3989 Автор
      15.11.2024 22:20

      Да, тип shippedOrder будет иметь только shippingDate: Date.

      Согласно условиям для дженериков получается следующая картина:

      Order<'paid'> = paidOrder C полями amount: number и paymentDate: Date

      Order<'shipped'> = shippedOrder C полями shippingDate: Date

      Теперь мы используем OrderStatus для определения типа Order. Один тип имеет сумму и дату оплаты, а другой дату отправки. В итоге получился динамический тип который изменяется относительно статуса заказа


      1. dom1n1k
        15.11.2024 22:20

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


        1. DmitryR3989 Автор
          15.11.2024 22:20

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


  1. donatello2005
    15.11.2024 22:20

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


  1. SergejT
    15.11.2024 22:20

    Пример с Identity вообще не вкурил.


  1. 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 {...}
    }


    1. DmitryR3989 Автор
      15.11.2024 22:20

      Спасибо, взял на вооружение). Пример с замыканиями действительно не валиден, так как я по невнимательности воткнул туда any. Ваш пример с unknown мне нравится больше. Я добавлю его в статью как пример. Приводить примеры не моя сильная сторона).


  1. meonsou
    15.11.2024 22:20

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

    Вы сами то хоть читали свои примеры? Где там сохраняются типы аргументов?


    1. DmitryR3989 Автор
      15.11.2024 22:20

      Исправил


  1. adminNiochen
    15.11.2024 22:20

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


    1. DmitryR3989 Автор
      15.11.2024 22:20

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


      1. meonsou
        15.11.2024 22:20

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


      1. 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


        1. DmitryR3989 Автор
          15.11.2024 22:20

          Виноват, статью поправил


  1. aleksand44
    15.11.2024 22:20

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

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


  1. 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

    Всем добра; надеюсь, никого не обидел


    1. Alexandroppolus
      15.11.2024 22:20

      посоветую по возможности избегать any и использовать вместо него unknown . Да, вы не сможете избежать его полностью, особенно когда будете писать сложные дженерики ...

      В одиночку, разумеется, unknown не может полностью заменить any, но в связке с never - запросто.


  1. isumix
    15.11.2024 22:20

    Зачем в заголовке "производительность"?

    Typescript это про проверку типов до запуска приложения, следственно он к производительности не имеет никакого отношения.