О том, как снизить риск дефектов программы на 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)
nin-jin
03.03.2022 18:25+4const cache = new Map< string, string >() const item = cache.get( 'foo' ) ?? 'bar'
Не благодарите.
bini1988
04.03.2022 11:49const cache = new Map< string, string >() const cacheCopy = {...cache}; const item = cacheCopy.get( 'foo' ) ?? 'bar'
Ваш код напомнил замечательный пример из какого-то доклада где TS позволяет таки выстрелить себе в ногу - валидный с точки зрения типом код сломает runtime.
nin-jin
04.03.2022 13:02https://habr.com/ru/post/565414/comments/#comment_23208762
Код этот в любом случае бестолковый. Что до рефакторинга, что после.
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 есть куча настроек для уровня строгости типизации, просто далеко не все согласны выкручивать всё на максимум. Имхо, проблема высосана из пальца
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), компилятор сообщит об ошибке. Тем самым попросит вас уточнить тип значенияПричем флаг влияет и на доступ к элементам массива, и на доступ к элементам объекта (по индексу)
nin-jin
04.03.2022 13:33+1TS не поддерживает завтипы, а значит вам придётся постоянно проверять на 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() ) }
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
на ровном местеnin-jin
04.03.2022 19:38Достаточно сильная система типов может покрыть любые хитрости.
Дырявые массивы лучше не использовать. А если и использовать то типизировать элементы как предложили выше.
AGrinko
05.03.2022 11:10Ну вот, есть флаг компилятора, любители строгой типизации могут быть частично довольны.
На практике, за много лет работы на крупных TypeScript-проектах, я, пожалуй, ни разу не сталкивался с вышеупомянутой ошибкой из-за выхода за границу массива.
Просто мне кажется, не стоит превращать TypeScript в Java, это не ускорит разработку. Хотите все плюшки строгой типизации - Java/C# + WebAssembly в руки, и вперёд :)
Nipheris
Это издёвка?