image


Всё больше и больше проектов и команд используют TypeScript. Однако просто применять TypeScript и выжимать из него максимум пользы — это очень разные вещи.


Представляю вам список высокоуровневых передовых практик использования TypeScript, которые помогут получить максимум преимуществ от применения этого языка.


Не лгите


Типы — это контракт. Что это значит? Когда вы реализуете функцию, её тип становится обещанием, данным другим разработчикам (или вам же самим в будущем!), что, будучи вызвана, эта функция вернет определенный тип значения.


В следующем примере тип функции getUser гарантирует, что она возвращает объект, у которого всегда есть два свойства: name и age.


interface User {
  name: string;
  age: number;
}

function getUser(id: number): User { /* ... */ }

TypeScript — очень гибкий язык. В нем много компромиссов, призванных облегчить внедрение языка. Например, он позволяет вам реализовать функцию getUser вот так:


function getUser(id: number): User {
  return { age: 12 } as User;
}

Не делайте так! Это ЛОЖЬ. Создавая такой код, вы ЛЖЕТЕ другим разработчикам (которые будут использовать вашу функцию в своих функциях). Они ожидают, что у объекта, возвращаемого функцией getUser, всегда будет какое-то поле name. Но его нет! Далее, что произойдет, когда ваш коллега напишет getUser(1).name.toString()? Вы прекрасно знаете, что…


Здесь, конечно, ложь выглядит очевидной. Однако, работая с большой базой кода, вы будете часто оказываться в ситуациях, когда значение, которое вы хотите вернуть (или передать), почти совпадает с ожидаемым типом. Чтобы найти причину несовпадения типов, нужны время и усилия, а вы торопитесь… поэтому вы решаете использовать приведение типов.


Однако, делая это, вы нарушаете священный контракт. ВСЕГДА лучше выделить время и понять, почему типы не совпадают, чем использовать приведение типов. Очень вероятно, что под поверхностью скрывается какой-нибудь баг времени выполнения.


Не лгите. Соблюдайте свои контракты.


Будьте точны


Типы — это документация. Документируя функцию, разве вы не хотите донести как можно больше информации?


// Возвращает объект
function getUser(id) { /* ... */ }

// Возвращает объект с двумя свойствами: name и age
function getUser(id) { /* ... */ }

// Если id является числом и пользователь с данным id существует,
// возвращает объект с двумя свойствами: name и age.
// В противном случае возвращает undefined.
function getUser(id) { /* ... */ }

Какой комментарий для функции getUser вам бы больше понравился? Чем больше вы знаете о том, что возвращает функция, тем лучше. Например, зная, что она может вернуть undefined, вы можете написать блок if для проверки того, определен ли объект, который вернула функция, — перед тем, как запрашивать свойства этого объекта.


Ровно то же самое и с типами: чем более точно описан тип, тем больше информации он передает.


function getUserType(id: number): string { /* ... */ }

function getUserType(id: number): 'standard' | 'premium' | 'admin' { /* ... */ }

Вторая версия функции getUserType гораздо более информативна, и поэтому вызывающий ее находится в гораздо более удобной ситуации. Обрабатывать величину легче, если вы наверняка знаете (контракты, помните?), что это будет одна из трех заданных строк, а не просто любая строка. Начать с того, что вы точно знаете — величина не может быть пустой строкой.


Рассмотрим более реальный пример. Тип State описывает состояние компонента, который запрашивает некоторые данные с бекэнда. Точен ли этот тип?


interface State {
  isLoading: boolean;
  data?: string[];
  errorMessage?: string;
}

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


Мы можем сделать тип намного более точным с помощью разграничивающих объединяющих типов (discriminated union types):


type State =
   | { status: 'loading' }
   | { status: 'successful', data: string[] }
   | { status: 'failed', errorMessage: string };

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


Будьте точны. Передавайте в своих типах как можно больше информации.


Начинайте с типов


Так как типы являются одновременно и контрактом, и документацией, они отлично подходят для проектирования ваших функций (или методов).


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


Типы чрезвычайно полезны в этом процессе. Обдумывание приводит к созданию сигнатур типов функций, связанных с решением вашей задачи. И это замечательно, потому что вы фокусируетесь на том, что делают ваши функции, вместо раздумий о том, как они это делают.


В React JS есть понятие компонента высшего порядка (Higher Order Components, HOC). Это функции, которые каким-либо образом расширяют заданный компонент. К примеру, вы можете создать компонент высшего порядка withLoadingIndicator, который добавляет индикатор загрузки в существующий компонент.


Давайте напишем сигнатуру типа для этой функции. Функция принимает на вход компонент и возвращает тоже компонент. Для представления компонента мы можем воспользоваться типом React ComponentType.


ComponentType является обобщенным типом (generic type), который параметризуется типом свойств компонента. withLoadingIndicator принимает компонент и возвращает новый компонент, который отображает либо оригинальный компонент, либо индикатор загрузки. Решение о том, что именно отобразить, принимается исходя из значения нового логического свойства — isLoading. Таким образом, возвращаемому компоненту необходимы те же свойства, что и оригинальному, добавляется лишь новое свойство isLoading.


Окончательно оформим тип. withLoadingIndicator принимает компонент типа ComponentType<P>, где P обозначает тип свойств. withLoadingIndicator возвращает компонент с расширенными свойствами типа P & { isLoading: boolean }.


const withLoadingIndicator = <P>(Component: ComponentType<P>) 
    : ComponentType<P & { isLoading: boolean }> =>
        ({ isLoading, ...props }) => { /* ... */ }

Разбираясь с типами функции, мы были вынуждены думать о том, что будет у нее на входе и что на выходе. Другими словами, нам пришлось проектировать функцию. Написать её реализацию теперь — проще простого.


Начинайте с типов. Пусть типы вынуждают вас сначала проектировать, и лишь после этого писать реализацию.


Примите строгость


Первые три заповеди требуют от вас уделять особое внимание типам. К счастью, решая эту задачу, вы не обязаны делать все самостоятельно — зачастую сам компилятор TypeScript даст вам знать, когда ваши типы лгут или когда они недостаточно точны.


Можно помочь компилятору выполнять эту работу еще лучше, включив флаг --strict. Это мета-флаг, который подключает все опции строгой проверки типов: --noImplicitAny, --noImplicitThis, --alwaysStrict, --strictBindCallApply, --strictNullChecks, --strictFunctionTypes и --strictPropertyInitialization.


Что делают это флаги? Говоря в общем, их включение приводит к увеличению количества ошибок компиляции TypeScript. И это хорошо! Больше ошибок компиляции — больше помощи от компилятора.


Посмотрим, как включение флага --strictNullChecks помогает выявить ложь в коде.


function getUser(id: number): User {
    if (id >= 0) {
        return { name: 'John', age: 12 };
    } else {
        return undefined;
    }
}

Тип getUser гарантирует, что функция всегда возвращает объект типа User. Однако посмотрите на реализацию: функция может также вернуть значение undefined!


К счастью, включение флага --strictNullChecks приводит к ошибке компиляции:


Type 'undefined' is not assignable to type 'User'.

Компилятор TypeScript обнаруживает ложь. Чтобы избавиться от этой ошибки, просто честно скажите всю правду:


function getUser(id: number): User | undefined { /* ... */ }

Примите строгость проверки типов. Пусть компилятор оберегает вас от ошибок.


Будьте в курсе


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


Часто бывает так, что новые возможности языка позволяют определить типы точнее и проверять их строже.


Например, в версии 2.0 были представлены Discriminated Union Types (я упомянул их в заповеди Будьте точны).


Версия 3.2 представила флаг компилятора --strictBindCallApply, который включает корректную типизацию для функций bind, call и apply.


Версия 3.4 улучшила выведение типов (type inference) в функциях высшего порядка, что облегчило использование точных типов при написании кода в функциональном стиле.


Моя позиция такова, что знакомство с возможностями языка, вводимыми в последних версиях TypeScript, на самом деле стоит того. Часто это может помочь вам следовать остальным четырем заповедям из списка.


Хорошей стартовой точкой является официальная дорожная карта TypeScript. Также будет неплохо регулярно проверять раздел TypeScript в Microsoft Devblog, так как все анонсы релизов выходят именно там.


Будьте в курсе новых возможностей языка, и пусть это знание работает на вас.


Резюме


Надеюсь, изложенный список показался вам полезным. Как всегда и во всем, не стоит слепо следовать этим заповедям. Но я твердо убежден, что эти правила сделают вас более хорошим TypeScript-разработчиком.


Буду рад увидеть ваши мысли на этот счет в комментариях.


Бонус


Понравилась эта статья о TypeScript? Уверен, вам также понравится и этот бесплатный PDF: 10 ошибок разработки на TypeScript, которые делают ваш код небезопасным

Комментарии (16)


  1. JustDont
    28.07.2019 06:59
    +3

    Спасибо за статью. TS нынче это вообще единственный выход для неTDD-разработки чего-нибудь немаленького на фронте. И чем больше людей смогут в типы, тем лучше; меньше будут брать TS и писать потом any везде, где типы сложные.


    1. kalininmr
      29.07.2019 01:27

      ещё dart есть. мне нравится.


  1. VolCh
    28.07.2019 07:11

    Кстати, а есть простая возможность включать strict на уровне файла? Ну или включить на проекте и выключать на уровне файла?


    1. JustDont
      28.07.2019 07:42

      Это ж на уровне линтера идёт, т.е. можно второе — глобально включить, а локально делать игнор правила, если очень хочется.


      1. Cerberuser
        28.07.2019 08:18

        Разве линтера? По крайней мере, собственно параметр strict — это параметр typescript, а не tslint. А в typescript можно ли локально игнорировать определённые правила? Для произвольной ошибки — да, есть // @ts-ignore, а для конкретной проверки?


        1. JustDont
          28.07.2019 08:23

          Да, пардон, перепутал с другими вещами. Вы всё верно пишете. И ts-ignore не умеет в конкретику. Пока что разбить код на strict и не strict можно только через два разных вызова компилятора.


  1. i86com
    28.07.2019 10:26
    +1

    Не критикую, просто интересуюсь.

    function getUserType(id: number): string { /*… */ }

    function getUserType(id: number): 'standard' | 'premium' | 'admin' { /*… */ }

    Вы пишете, что вторая функция лучше, так как более информативна. Но что делать, если (когда) добавится новый тип юзеров, 'banned', например? Ходить по всем таким функциям и исправлять?
    А если мы пишем аналогичные функции getUserCountryName и getUserCurrencyCode — тоже в них все 150 возможных ответов перечислять?


    1. indestructable
      28.07.2019 10:44
      +4

      Можно тип объявить. Я думаю, автор написал инлайн-объявление просто для примера.


    1. orion76
      28.07.2019 10:46
      +3

      Если тип используется больше одного раза, то можно определить для него «алиас»:

      export type UserType='standard' | 'premium' | 'admin' ;
      


      тогда объявление функции приобретет такой вид:
      function getUserType(id: number):UserType { /*… */ }
      


      А если мы пишем аналогичные функции getUserCountryName и getUserCurrencyCode — тоже в них все 150 возможных ответов перечислять?


      тут тип возвращаемого функцией значения скорее всего будет просто «string»


    1. bevalorous Автор
      28.07.2019 10:48
      +2

      В комментариях к оригинальной статье автору советуют использовать enum для подобных случаев: https://dev.to/lexlohr/comment/d831


      1. dShell
        30.07.2019 01:53

        лучше использовать const enum. Но вроде бы, это не совместимо с babel (sic) транспайлером.


    1. Skara
      28.07.2019 10:56

      1. Используйте type alias:

      export type UserType = 'standard' | 'premium' | 'admin'

      Также хорошая альтернатива — string enum:
      export enum UserType { Standard = 'standard' /*...*/ }

      2. Это немного другой сценарий — очень много вариантов. Например, зачастую конкретные currencyCode при этом просто не используются — все приходит с сервера или выбирается юзером. Но если они все таки нужны, можно решить проблему константами, например ввести DEFAULT_CURRENCY_CODE для предзаполнения дропдауна. Система типов для этого полезна, но не является оптимальным решением.


    1. VolCh
      28.07.2019 18:55

      Вот только сегодня доработал этот подход, способом возможным с, кажется, 3.4:


      // possible values
      const statuses = ["new", "approving", "approved", "published", "depublished"] as const;
      // pseudo-enum 
      export type Status = typeof statuses[number];
      // type guard
      export const isStatus = (value: any): value is Status => (
        typeof value === 'string' && statuses.some(status => status === value)
      );


  1. mQm
    28.07.2019 11:02
    +3

    По поводу документирования функции. Если она правильно оформлена с указанием входящих и исходящих типов данных, то она уже является самодокументированной — зачем тратить время на многострочное описание того, что грамотный разработчик прочтет в самой функции?


    1. bevalorous Автор
      28.07.2019 11:08
      +1

      Многострочное описание — не нужно. Но коротко описать важные детали реализации бывает полезно:

      • читать код реализации функции может быть довольно утомительно просто в силу объема
      • по деталям реализации может быть не совсем понятно, что именно делает функция
      • подобное описание может использоваться для генерации документации


      1. VolCh
        30.07.2019 05:49

        • значит функция недостаточно декомпозирована
        • это должно быть понятно из её имени
        • вот тут согласен, правда некоторые средства генерации документации иногда ведут себя странно, игнорируют фактическую сигнатуру функции, перекрывая её тем, что описано в в doc. Почему я от них отказался для внутренних разработок. *doc должен дополнять код, а не переопределять его.

        В любом случае плохо бросаться