О том, как снизить риск дефектов программы на TypeScript, величину технического долга проекта и, одновременно, улучшить читаемость кода TypeScript.

Фабрика кэша

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

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

const createCache = <T>()=>{
  const items:T[] = [];
  return {
    getItem: (key:number)=>items[key],
    setItem: (key:number, item:T)=>{items[key]=item;},
  };
};

Обратитесь к песочнице и выясните каким образом TypeScript типизирует результат создания кэша.

const {getItem, setItem} = createCache<string>();

В частности getItem имеет тип (key: number) => string. Правда ли что в кэше всегда есть нужное нам значение? Нет! Ведь значение в кэш нужно еще положить. До этого момента результат getItem не возвращает строку.

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

Быть может ошибочное суждение о типе результата доступа по индексу характерно для TypeScript только в отношении массива? Посмотрим.

Индекс ассоциативного массива (чисто объекта)

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

interface Dictionary<T> {
  [term: string]: T;
}
const createCache = <T>()=>{
  const items:Dictionary<T> = {};
  return {
    getItem: (key:string)=>items[key],
    setItem: (key:string, item:T)=>{items[key]=item;},
  };
};

const {getItem, setItem} = createCache<string>();

И все равно getItem, по мнению TypeScript, возвращает строку.

Риск дефекта

Уже два примера убеждают нас, что TypeScript где-то не дорабатывает. Как компилятор, так и сервисы в редакторе, в один голос твердят, что результатом работы функции getItem будет строка. Мы, поверив компилятору, попытаемся работать с неинициализированным значением дальше и, рано или поздно, наткнемся на ошибку во время исполнения. Ошибка-то будет где-то у клиента. Пойди, найди причину!

Ручное управление

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

const createCache = <T>() => {
  const items: T[] = [];
  return {
    getItem: (key: number) => items[key] as T | undefined,
    setItem: (key: number, item: T) => { items[key] = item; },
  };
};

const { getItem, setItem } = createCache<string>();

Замечание. в отличие от c#, например, в TypeScript утверждение типа `as type` не влечет за собой никаких накладных расходов во время исполнения программы. Этот оператор используется на этапе статического анализа кода и просто удаляется после компиляции. В результирующую программу он не попадает.

В отрывке приведенном ниже, сразу после инициализации считается, что переменная item содержит значение типа string | undefined. Нам приходится провести проверку полученного значения, тем самым устранить риск дефекта.

TypeScript дальше проявляет свою сообразительность и понимает, методом исключения, раз не undefined, значит string и радостно компилирует программу без ошибок.

const { getItem, setItem } = createCache<string>();

const item = getItem(0);
if(typeof item !== "undefined"){
  console.log(item.toUpperCase());
}

Но мы можем поступить лучше.

Флаги строгости: noUncheckedIndexedAccess

Компилятор tsc TypeScript-а управляется флагами из tsconfig.json. Cреди прочих флагов есть флаги "строгости". Для включения многих из них достаточно указать флаг strict. Установка этого флага неявно включает, в частности, все флаги, имена которых начинаются со слова strict.

Флаг noUncheckedIndexedAccess включается отдельно. Но когда он включен, TypeScript автоматически объединяет тип значения, полученного доступом по индексу, и тип undefined, таким образом заставляя нас перепроверить наличие полезного значения каждый раз, когда мы извлекаем что-то через квадратные скобки.

В этой песочнице, на закладке TSConfig установлен флаг noUncheckedIndexedAccess и как результат, тип функции getItem магически улучшился до: (key: number) => string | undefined. Ручная настройка определения функции не потребовалась.

В сухом остатке

Разработка программы для повседневного использования, а не для игры в песочнице, часто сопровождается накоплением технического долга. В погоне за скоростью, разработчик может принять решение позднее вернуться к коду и добавить аннотацию типа T | undefined. И, конечно, не возвращается, пока не придет время исправлять обнаруженный заказчиком дефект.

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


И, вот когда с типами все стало лучше, пользователь отправил в кэш значение с ключом (-1)... Как быть, ведь в TypeScript нет безнаковых целых чисел?... Об этом в следующий раз.

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


  1. Nipheris
    03.03.2022 16:15
    +4

    Этот рисунок в коде превращается в кэш

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

    Это издёвка?


  1. nin-jin
    03.03.2022 18:25
    +4

    const cache = new Map< string, string >()
    const item = cache.get( 'foo' ) ?? 'bar'

    Не благодарите.


    1. bini1988
      04.03.2022 11:49

      const cache = new Map< string, string >()
      const cacheCopy = {...cache};
      const item = cacheCopy.get( 'foo' ) ?? 'bar'

      Ваш код напомнил замечательный пример из какого-то доклада где TS позволяет таки выстрелить себе в ногу - валидный с точки зрения типом код сломает runtime.


      1. nin-jin
        04.03.2022 13:02

        https://habr.com/ru/post/565414/comments/#comment_23208762

        Код этот в любом случае бестолковый. Что до рефакторинга, что после.


  1. AGrinko
    04.03.2022 13:01
    +1

    И все равно getItem, по мнению TypeScript, возвращает строку.

    Я что-то не догоняю... Вы же явно указали тип string для элементов кеша:

    const { getItem, setItem } = createCache<string>()

    Естественно, теперь getItem возвращает только string, а как иначе? Хотите безопасную работу с null/undefined? createCache<string|null|undefined> (либо ваше решение, с встроенным undefined)

    Хотите кеш с любыми типом значений? createCache<unknown>

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

    Кроме того, в TypeScript есть куча настроек для уровня строгости типизации, просто далеко не все согласны выкручивать всё на максимум. Имхо, проблема высосана из пальца


    1. Maxim-Wolf Автор
      04.03.2022 13:17

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

      Ведь если компилятор решит, что item это string, но не будет вас останавливать от действия

      const items:string[] = [];//пустой массив
      const item = items[0]; //item: string - а это неверно.
      console.log(item.toUpperCase());// Нет ошибки без фалага noUncheckedIndexedAccess
      //          ^^^^ Object is possibly 'undefined'. при наличии noUncheckedIndexedAccess

      При наличии флага компилятор присвоит item:string|undefined. Это уже лучше. Если вы попробуете выполнить эти же действия с полученным значением (без проверки на undefinend), компилятор сообщит об ошибке. Тем самым попросит вас уточнить тип значения

      Причем флаг влияет и на доступ к элементам массива, и на доступ к элементам объекта (по индексу)


      1. nin-jin
        04.03.2022 13:33
        +1

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

        const items : string[] = [ 'foo' ]
        const item = items[0]
        if( item === undefined ) throw new Error( 'Matrix has you!' )
        console.log( item.toUpperCase() )
        
        for( let i = 0; i < items.length; i += 2 ) {
            const item = items[i]
            if( item === undefined ) throw new Error( 'Matrix has you!' )
            console.log( item.toUpperCase() )
        }


        1. Maxim-Wolf Автор
          04.03.2022 18:20

          Вы правы. TypeScript не волшебник и не дорос до зависимых типов (может ли вообще дорости имея корни в JavasScript?). Все хитрости JavaScript тянутся за ним хвостом. А стимулирует ли вас к проверке, как вы ее и написали, если вспомнить что часть элементов массива может остаться не инициализированной?

          const items : string[] = [ 'foo' ];
          items[4] = 'fifth';
          const item = items[0]
          if( typeof item === 'undefined' ) throw new Error( 'Matrix has you!' )
          console.log( item.toUpperCase() )
          
          for( let i = 0; i < items.length; i += 2 ) {
              const item = items[i]
              if( typeof item === 'undefined' ) continue;
              //без проверки - будет ошибка исполнения
              console.log( item.toUpperCase() )
          }

          Согласитесь, дело в том, что доступ по индексу и JavaScript может вернуть undefined на ровном месте


          1. nin-jin
            04.03.2022 19:38

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

            Дырявые массивы лучше не использовать. А если и использовать то типизировать элементы как предложили выше.


      1. AGrinko
        05.03.2022 11:10

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

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

        Просто мне кажется, не стоит превращать TypeScript в Java, это не ускорит разработку. Хотите все плюшки строгой типизации - Java/C# + WebAssembly в руки, и вперёд :)