company_banner

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

    Мы используем TypeScript, потому что это делает разработку безопаснее и быстрее.

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

    Я собрал ряд настроек и принципов для более строгого использования TypeScript. К ним нужно привыкнуть один раз — и они сэкономят массу времени в будущем.

    any

    Самое простое правило, которое дает моей команде много профита в долгосрочной перспективе: 

    Не использовать any.

    Практически нет ситуаций, когда нельзя описать тип вместо использования any. Если я попал в ситуацию, когда приходится написать any в долгосрочном проекте, обычно получается найти проблемы в архитектуре либо это легаси-код.

    Используйте дженериковые типы, unknown или перегрузки — и сможете забыть о неожиданных ошибках при работе с данными. Такие проблемы иногда сложно отловить и достаточно дорого дебажить.

    Если все же вам нужно опустить типизацию, советую воспользоваться алиасами на any, почитать о которых можно в статье моего коллеги — совет № 3.

    strict

    В TypeScript есть классный strict-режим, который, к сожалению, отключен по умолчанию. Он включает набор правил для безопасной и комфортной работы с TypeScript. Если вы совсем не знакомы с этим режимом, прочтите вот эту статью.

    Со strict-режимом вы забудете про ошибки вроде undefined is not a function и cannot read property X of null. Ваши типы будут точными и правильными.

    А что делать-то?

    Если стартуете новый проект, то просто сразу включайте strict и будьте счастливы.

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

    Как правило, самый удачный подход — разбить эту огромную задачу на куски. Сам по себе strict-режим является набором из шести правил. Вы можете включить одно из них и поправить все ошибки. Теперь ваш проект стал чуть более строгим. В следующий раз включите еще одно правило, поправьте все ошибки и продолжайте работать. В один день вы соберете весь strict-режим!

    Но в больших проектах бывает не все так гладко, тогда можно действовать более итеративно. Включите флаг, а над всеми конфликтами поставьте @ts-ignore и комментарий с TODO. В следующий раз при работе с файлом заодно поправите и тип.

    // tsconfig.json file
    {
        // ...,
        "compilerOptions": {
            // a set of cool rules
            "noImplicitAny": true,
            "noImplicitThis": true,
            "strictNullChecks": true,
            "strictPropertyInitialization": true,
            "strictBindCallApply": true,
            "strictFunctionTypes": true,
            // a shortcut enabling 6 rules above
            "strict": true,
            // ...
        }
    }

    readonly

    Следующее важное для меня правило — везде писать readonly.

    Мутировать структуры, с которыми работаешь, — плохая практика. Например, у нас в Angular-мире это сразу приводит к ряду последствий в приложении: появляются проблемы с проверкой изменений в компонентах, за которыми не происходит обновления отображения после мутирования данных.

    Но можем ли мы легко предотвратить все попытки мутировать данные? Для себя я просто сформировал привычку писать readonly везде.

    Что делать?

    В вашем приложении, скорее всего, есть множество мест, где можно заменить небезопасные типы их readonly-альтернативами.

    Используйте readonly в интерфейсах:

    // before
    export interface Thing {
        data: string;
    }
    
    // after
    export interface Thing {
        readonly data: string;
    }

    Предпочитайте readonly в типах:

    // Before
    export type UnsafeType = { prop: number };
    
    // After
    export type SafeType = Readonly<{ prop: number }>;

    Используйте readonly поля класса везде, где это возможно:

    // Before
    class UnsafeComponent {
        loaderShow$ = new BehaviorSubject<boolean>(true);
    }
    
    // After
    class SafeComponent {
        readonly loaderShow$ = new BehaviorSubject<boolean>(true);
    }

    Используйте readonly-структуры:

    // Before
    const unsafeArray: Array<number> = [1, 2, 3];
    const unsafeArrayOtherWay: number[] = [1, 2, 3];
    
    // After
    const safeArray: ReadonlyArray<number> = [1, 2, 3];
    const safeArrayOtherWay: readonly number[] = [1, 2, 3];
    
    // three levels
    const unsafeArray: number[] = [1, 2, 3]; // bad
    const safeArray: readonly number[] = [1, 2, 3]; // good
    const verySafeTuple: [number, number, number] = [1, 2, 3]; // super
    const verySafeTuple: readonly [number, number, number] = [1, 2, 3]; // awesome (after v3.4)
    
    // Map:
    // Before
    const unsafeMap: Map<string, number> = new Map<string, number>();
    
    // After
    const safeMap: ReadonlyMap<string, number> = new Map<string, number>();
    
    
    // Set:
    // Before
    const unsafeSet: Set<number> = new Set<number>();
    
    // After
    const safeSet: ReadonlySet<number> = new Set<number>();

    as const

    В TypeScript v3.4 появились const-assertions. Это более строгий инструмент, чем readonly-типы, потому что он запаковывает вашу константу с наиболее точным типом из возможных. Теперь можно быть уверенным: никто и ничто не сможет это изменить.

    Кроме того, при использовании as const ваша IDE всегда будет показывать точный тип используемой сущности.

    Utility Types

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

    Советую подробно изучить всю официальную документацию по Utility Types и начать активно внедрять их в свои приложения. Они тоже экономят массу времени.

    Сужения типов

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

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

    (этот же пример можно запустить на repl.it)

    import {Subject} from 'rxjs';
    import {filter} from 'rxjs/operators';
    
    interface Data {
      readonly greeting: string;
    }
    
    const data$$ = new Subject<Data | null>();
    
    /**
     * source$ все еще имеет тип "Observable<Data | null>"
     * хотя "null" никогда не пройдет функцию filter
     * 
     * Это произошло, потому что TS не может быть уверен, что тип данных изменился.
     * Функция "value => !!value" возвращает boolean, но ничего не говорит о типах
     */
    const source$ = data$$.pipe(
      filter(value => !!value)
    )
    
    /** 
     * А вот это хорошо типизированный пример
     * 
     * Эта стрелочная функция отвечает на вопрос, является ли value типом Data.
     * Это сужает тип, и теперь "wellTypedSource$" типизирован правильно
     */
    const wellTypedSource$ = data$$.pipe(
      filter((value): value is Data => !!value)
    )
    
    // Это не скомпилируется, можете проверить :)
    // source$.subscribe(x => console.log(x.greeting));
    
    wellTypedSource$.subscribe(x => console.log(x.greeting));
    
    data$$.next({ greeting: 'Hi!' });

    Вы можете сужать типы несколькими методами:

    • typeof — оператор из JavaScript для проверки примитивных типов.

    • instanceof — оператор из JavaScript для проверки унаследованных сущностей.

    • is T — декларирование из TypeScript, которое позволяет проверять сложные типы или интерфейсы. Будьте осторожны с этой возможностью, потому что так вы перекладываете ответственность за определение типа с TS’а на себя.

    Несколько примеров:

    (эти же примеры можно запустить на repl.it)

    // typeof narrowing
    function getCheckboxState(value: boolean | null): string {
       if (typeof value === 'boolean') {
           // value has "boolean" only type
           return value ? 'checked' : 'not checked';
       }
    
       /**
        * В этой области видимости value имеет тип “null”
        */
       return 'indeterminate';
    }
    
    // instanceof narrowing
    abstract class AbstractButton {
       click(): void { }
    }
    
    class Button extends AbstractButton {
       click(): void { }
    }
    
    class IconButton extends AbstractButton {
       icon = 'src/icon';
    
       click(): void { }
    }
    
    function getButtonIcon(button: AbstractButton): string | null {
       /**
        * После "instanceof" TS знает, что у объекта кнопки есть поле "icon"
        */
       return button instanceof IconButton ? button.icon : null;
    }
    
    // is T narrowing
    interface User {
       readonly id: string;
       readonly name: string;
    }
    
    function isUser(candidate: unknown): candidate is User {
       return (
           typeof candidate === "object" &&
           typeof candidate.id === "string" &&
           typeof candidate.name === "string"
       );
    }
    
    const someData = { id: '42', name: 'John' };
    
    if (isUser(someData)) {
       /**
        * Теперь TS знает, что someData имплементирует интерфейс User
        */
       console.log(someData.id, someData.name)
    }

    Итого

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

    Tinkoff
    it’s Tinkoff — просто о сложном

    Похожие публикации

    Комментарии 31

      +3
      И каким образом всё это помогает «масштабировать приложения бесконечно»?
        0
        Привет! От увеличения размера проектов, работать над ними не становится существенно сложнее: подобная строгая типизация позволяет орудовать сущностями, не ожидая от них подставы и не держа весь код проекта и его взаимосвязи в голове
          0
          Всё это понятно. Вопрос в том, как это связано с масштабированием?
            +2

            Имеется в виду масштабирование кодовой базы — вроде очевидно.

              –3
              Понимаете, я против размывания терминов. Под масштабированием всю жизнь понимали способность системы справляться с возросшей нагрузкой при добавлении вычислительных мощностей (вертикальном или горизонтальном), как правило, без переписывания кода! Разумеется всё это понималось в отношении бакэнда, а не фронта. И вот внезапно появляется «масштабирование кодовой базы», которое внезапно означает совсем… другое.

              Мне нравится статья, она даёт правильные советы, но… заголовок — он какой-то слишком кричащий. «Масштабировать»… «бесконечно». Автору стоило бы уточнить, что речь идёт о совсем другом масштабировании. На мой взгляд, конечно. Что-то вроде «Вы могли бы заниматься масштабированием вашей кодовой базы неограниченно» (оптимизм автора насчёт бесконечного масштабирования мне тоже нравится, да).
                +1

                Приложение и малого превращается в большое. Это разве не масштабирование? Тут речь идёт не о системе, а о кодовой базе. Автор мог запутать неправильной формулировкой

                  0
                  да, разумеется, под масштабированием имел в виду только увеличение размеров проекта. Я использую слово «масштабировать» довольно часто для всего, что может расти, поэтому для меня оно хорошо воспринимается и вне контекста бэкенда или разговоров о базах данных. Тем не менее, извиняюсь, если кого-то запутал этим :(
                  0
                  Под масштабированием всю жизнь понимали способность системы справляться с возросшей нагрузкой при добавлении вычислительных мощностей (вертикальном или горизонтальном), как правило, без переписывания кода!

                  Если взять AWSовское определние: "A measurement of a system's ability to grow to accommodate an increase in demand" — про вычислительные мощности нету, есть про увеличение потребностей и про способность расти.

                    0
                    Если взять AWSовское определние: «A measurement of a system's ability to grow to accommodate an increase in demand»
                    А где там про переписывание кода? AWS — это само по себе про вычислительные мощности.
                      0

                      Как вы живёте вообще?

                        0
                        Вашими молитвами. А почему Вы интересуетесь моей жизнью?
                          0

                          Ну серьёзно — одно и то же определение прочитанное в доках AWS и на порнхабе должно интерпретироваться по-разному?

                            0
                            Не могу сказать. Я слабо знаком проблематикой порнхаба. Что вас заставило сделать такой вывод? Серьёзно, что вас заставляет в этом треде поминать порнхаб?
                              0

                              Это такая эпотажная попытка проиллюстрировать бестолковость вашего подхода. Масштабируемость — это масштабируемость, независимо от того, где используется это слово. То что в "бытовом смысле" под масштабируемостью почти всегда понимается "быстро и много", совершенно не означает, что теперь нельзя говорить про "масштабируемость кода". В быту также слово "программист" означает "может починить компьютер", а "интернет" означает "яндекс". У нас тут хабр, а не посиделки с домохозяйками :-)

                                0
                                Оставлю это здесь. Именно в таком виде этот термин преподавали нам в университете. Лет 20 назад. Или больше, я немного сбился со счёта, но это было еще на ЕС 1045. На счёт бытового смысла или порнхаба ничего не скажу, не знаком с такими трактовками. Что касается «масштабирования кодовой базы», то это несколько более новое веяние и упоминать его желательно именно в такой уточняющей форме. Просто чтобы не путать читателя.
            0

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

            0
            везде писать readonly.
            Удобнее оборачивать в DeepReadonly, как минимум все аргументы функций/методов/конструкторов. Кроме этого тип NoExtraProps может быть полезен. Этих типов нет в стандартном наборе TS но их легко найти в поисковике или самому написать кто любит задрачивать на TS типы.
              0
              DeepReadonly — катастрофически замедляет автокомплит в ide — слишком увлекаться не стоит.
              0
              Я бы добавил еще сюда кастомные Type Guard-ы. Сужение типов — это, конечно, близко, но не совсем то же самое. Во всяком случае официальная документация их различает и про guard-ы есть отдельный раздел.

              UPD. Перечитал про «сужение» еще раз. Мне показалось, там несколько техник сведены под одним заголовком. Есть пример c «is», но я бы еще добавил пример c «asserts».
                –2
                Мы используем TypeScript, потому что это делает разработку безопаснее и быстрее.

                Безопаснее разработку с тайпскриптом делают Ваши правила, а не сам язык, в котором, позволен изначально делать небезопасно…
                  +5

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

                    0
                    спасибо за статью, почему-то ваши статьи уже 2 или 3 раз не отображаются у меня в ленте на:
                    habr.com/ru/hub/webdev
                    Если бы не был подписан — не узнал бы, зато статьи от рувдс никак скрыть не могу(
                      0
                      Спасибо за фидбек! Это из-за того, что я не указал среди хабов «Разработка веб-сайтов» — туда можно указать четыре тематических хаба и я как-то стал забывать про этот, указывая обычно Angular + TS + JS и еще что-нибудь. Похоже, стоит включать :)
                        +1

                        Да, было бы здорово, это общий хаб который объединяет все перечисленные)
                        Спасибо)

                      +3
                      Столько желчи в комментариях. Почему просто не пройти мимо, если не узнали что-то новое? Для меня было полезно, спасибо автору!
                        +1
                        Видимо не хватает четвёртого варианта:

                        // four levels
                        const unsafeArray: number[] = [1, 2, 3]; // bad
                        const safeArray: readonly number[] = [1, 2, 3]; // good
                        const verySafeTuple: [number, number, number] = [1, 2, 3]; // awesome
                        const superSafeTuple: readonly [number, number, number] = [1, 2, 3]; // super
                        
                          0
                          Спасибо вам, это очень полезный комментарий!
                          Из-за того, что мы нигде ничего не мутируем, в моей команде и не знали, что туплы по дефолту мутабельные и мы можем сделать на тупле в данном случае .pop() пару раз, нагло нарушив контракт переменной

                          Кстати, у вас есть идеи, для чего может существовать мутабельный «тупл»? (фактически, он и не тупл в таком случае) Сходу выглядит как беда TypeScript, на которую стоит завести issue
                            +1
                            К примеру кортежи в TypeScript используются для того чтобы итерировать данные в которых есть ключ и значение (Map, URLSearchParams и т.д). Я встречал подход когда иммутабельность используется не повсеместно а только в публичных методах, это избавляло от избыточного создания одноразовых объектов/массивов. Тут как-раз может пригодиться мутабельность кортежей.
                            –1
                            Ого, Дима, это ты, привет!
                              –1
                              Привет!
                            0

                            К пункту "не использовать any". Типы можно "доставать" из окружения — переменных, массивов, аргументов функций и их ReturnType. Это конечно не очень красиво, но на порядок лучше any — так что, используйте с умом.


                            // 1. из переменной
                            let foo = { foo: 'f' };
                            let bar: typeof foo = null;
                            // 2. из массива
                            let foo = [{ foo: 'f' }];
                            let bar: (typeof foo)[0] = null;
                            // 3. из аргументов
                            function doBar (foo: { foo: string }) {
                            }
                            let bar: Parameters<typeof doBar>[0] = null;
                            // 4. из возврата
                            function doBar () {
                                return { foo: 'f' };
                            }
                            let bar: ReturnType<typeof doBar> = null;

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

                            Самое читаемое