А что, если бы функция get в Lodash выводила типы за вас в TypeScript

—

от автора

Всем привет! ?

⚠️ Возрождаю статью, мною написанную в 2021 на Хабре. Из нового — подкорректировал определения на русском, добавил куски кода, чтобы было удобно копировать. Английский оригинал тут — https://blog.beraliv.dev/2021-03-26-typed-get. Приятного чтения ?

type Get<O, P> = never; // реализовать  type Step1 = Get<   { article: { author: "Alexey Berezin"; keywords: ["typescript"] } },   "article.keywords.0" >; type Step2 = Get<   { author: "Alexey Berezin"; keywords: ["typescript"] },   "keywords.0" >; type Step3 = Get<["typescript"], "0">; type Step4 = Get<"typescript", "">; type Result = "typescript";

В 2021 году я раскопал на просторах GitHub репозиторий type-challenges. У меня есть целый блог, где я решаю задачки оттуда, но сегодня я попытаюсь показать не только реализацию Get, но и продемонстрирую общие проблемы, покажу улучшения и использование в production.

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

1. Базовая реализация

Текущий челлендж располагается в категории «сложное».

У нас есть объект и путь в этом объекте (реализация не предполагает, что пути в массиве и/или кортеже). Ожидается, что тип вернет корректный тип по пути в этом объекте.

Так с чего же начнем?

1.1. Получение ключей

Представим, если бы мы решали эту задачу с помощью JavaScript:

const get = (obj, path) => {   const keys = path.split(".");    return keys.reduce((currentObj, key) => currentObj[key], obj); };

Перед тем, как вызывать keys.reduce, мы получаем список всех ключей. В JavaScript нам достаточно вызвать метод split. В TypeScript нам тоже надо как-то получить список ключей из строки.

Благодаря TypeScript 4.1, мы можем использовать Template Literal types. С их помощью мы можем удалить точки между ключами. Давайте определим тип Path и попробуем сделать первый подход:

type Path<T> = T extends `${infer Key}.${infer Rest}`   ? [Key, ...Path<Rest>]   : [];

Выглядит коротко и просто. Однако после покрытия тестами мы поняли, что упустили случай с единственным элементом (без точки). Тесты написаны в Playground. Давайте добавим этот случай:

type Path<T> = T extends `${infer Key}.${infer Rest}`   ? [Key, ...Path<Rest>]   : T extends `${infer Key}`   ? [Key]   : [];

Так лучше. Тесты вместе с реализацией доступны в Playground.

1.2. Reducer для объекта

После того, как мы получили ключи, мы наконец-то можем вызватьkeys.reduce. Чтобы это сделать, давайте определим тип GetWithArray , имея уже путь в виде кортежа K

type GetWithArray<O, K> = K extends [infer Key, ...infer Rest]   ? Key extends keyof O     ? GetWithArray<O[Key], Rest>     : never   : never;

Немного прокомментирую:

  1. K extends [infer Key, ...infer Rest] проверяет, что у нас есть хотя бы один элемент в кортеже

  2. Key extends keyof O позволяет использовать O[Key] и рекурсивно переходит к следующему уровню объекта

Давайте протестируем это решение (ссылка на Playground). Опять мы забыли случай, правда уже когда у нас пустой массив. После добавления код выглядит так:

type GetWithArray<O, K> = K extends []   ? O   : K extends [infer Key, ...infer Rest]   ? Key extends keyof O     ? GetWithArray<O[Key], Rest>     : never   : never;

Финальная версия с тестами в Playground

1.3. Все вместе

type Get<O, P> = GetWithArray<O, Path<P>>;

Давайте протестируем все вместе и удостоверимся, что тип работает как ожидается: Playground. Отлично, базовую часть мы закончили.

2. Опциональные пути

Когда работаешь с реальными данные в production, тебе иногда данные не приходят или приходят, но не полностью. Поэтому по всему проекту мы используем ? , null илиundefined.

Возьмем такой пример и покроем тестами текущее решение: Playground. Как и ожидалось, TypeScript ругается.

Причина проста. Давайте возьмем какой-нибудь пример и пошагово пройдемся:

type ProductionObject = {   description?: string;   title: string;   date: string;   author?: {     name: string;     location?: {       city: string;     };   }; };  type Step1 = Get<ProductionObject, "author.name">; type Step2 = Get<   | {       name: string;       location?:         | {             city: string;           }         | undefined;     }   | undefined,   "name" >; type Step3 = Get<never, "">; type Result = never; // ожидалось `string | undefined`

Текущее решение не позволяет извлекать ключ из объекта, который может быть undefined or null. Постараемся это решить.

2.1. Удаляем undefined, null или оба типа сразу

Сначала определим 3 вспомогательных типа:

type FilterUndefined<T> = T extends undefined ? never : T;  type FilterNull<T> = T extends null ? never : T;  type FilterUndefinedAndNull<T> = FilterUndefined<FilterNull<T>>;

Мы проверяем, что undefined и/или null являются частью union type, и если так, удаляем их из него. Это поможет работать с остальной «существенной» частью.

Тесты, как обычно, в Playground

2.2. Редактируем reducer

Давайте обновим вот эту ветку GetWithArray:

type GetWithArray<O, K> = K extends []   ? O   : K extends [infer Key, ...infer Rest]   ? Key extends keyof O     ? GetWithArray<O[Key], Rest>     : never // <= добавляем логику здесь ?   : never;
  1. Сначала проверим, что ключ существует в объекте с undefined и/или null

  2. В противном случае, его нет (то есть мы возвращаем undefined)

type GetWithArray<O, K> = K extends []   ? O   : K extends [infer Key, ...infer Rest]   ? Key extends keyof O     ? GetWithArray<O[Key], Rest>     : Key extends keyof FilterUndefinedAndNull<O>     ? GetWithArray<FilterUndefinedAndNull<O>[Key], Rest> | undefined     : undefined   : never;

Добавим здесь тесты и проверим, что тип работает корректно (ссылка на Playground).

3. Получение пути из массива и кортежи

Аналогично берем пример с массивом и пошагово проверяем:

type ProductionObject = {   posts: {     title: string;     description?: string;     poster?: string;     html: string;   }[]; };  type Step1 = Get<ProductionObject, "posts.0">; type Step2 = Get<   {     title: string;     description?: string;     poster?: string;     html: string;   }[],   "0" >; type Step3 = Get<undefined, "">; type Result = undefined; // ? ожидалось `string | undefined`

В JavaScript мы бы ходили по индексам:

const get = (arr, path) => {   const keys = path.split(".");    return keys.reduce((currentArr, key) => currentArr[key], arr); };

Несмотря на то, что ключ может быть string или number, Path оставляем неизменным:

type Path<T> = T extends `${infer Key}.${infer Rest}`   ? [Key, ...Path<Rest>]   : T extends `${infer Key}`   ? [Key]   : [];

3.1. Reducer для массива

Как и для объектов, для массивов мы вызываем keys.reduce . Для TypeScript нам надо написать реализацию аналогично GetWithArray . Давайте реализуем это отдельно для массивов, а затем объединим реализации GetWithArray в одно.

Сперва адаптируем тип для массивов и кортежа. Возьмем A вместо O по семантическим причинам:

type GetWithArray<A, K> = K extends []   ? A   : K extends [infer Key, ...infer Rest]   ? Key extends keyof A     ? GetWithArray<A[Key], Rest>     : never   : never;

После тестирования в Playground, мы столкнулись с несколькими проблемами:

1. Массивы не имеют ключей с типом  string :

type Step1 = GetWithArray<string[], "1">; type Step2 = "1" extends keyof string[] ? string[]["1"] : never; type Result = never; // ? ожидалось `string | undefined`

Здесь '1' extends keyof string[] всегда ложно, поэтому возвращает never.

2. Аналогично для массивов с ключевым словом readonly

type Step1 = GetWithArray<readonly string[], "1">; type Step2 = "1" extends keyof (readonly string[])   ? (readonly string[])["1"]   : never; type Result = never; // ? ожидалось `string | undefined`

3. Кортежи (например [0, 1, 2]) возвращают never вместо undefined:

type Step1 = GetWithArray<[0, 1, 2], "3">; type Step2 = "3" extends keyof [0, 1, 2] ? [0, 1, 2]["3"] : never; type Result = never; // ? ожидалось `undefined`

Пойдем чинить все пошагово.

3.2. Выводим T | undefined

type GetWithArray<A, K> = K extends []   ? A   : K extends [infer Key, ...infer Rest]   ? Key extends keyof A     ? GetWithArray<A[Key], Rest>     : never // <= добавляем логику здесь ?   : never;

Для массивов мы хотим получить T | undefined в качестве ответа (так как при извлечении по индексу мы не знаем, есть элемент или нет), в зависимости от значения T:

type GetWithArray<A, K> = K extends []   ? A   : K extends [infer Key, ...infer Rest]   ? Key extends keyof A     ? GetWithArray<A[Key], Rest>     : A extends readonly (infer T)[]     ? GetWithArray<T | undefined, Rest>     : never   : never;

Я добавил A extends readonly (infer T)[] , т.к. для всех массивов (в том числе с ключевым слово readonly) это утверждение верно.

После проверки, нам остается починить кортежи. Пример с тестами доступен в Playground.

3.3. Кортежи

Если мы попробуем извлечь значение из несуществующего индекса, мы получим обобщающий тип, как для массивов (ну и еще undefined в придачу)

type Step1 = GetWithArray<[0, 1, 2], "3">; type Step2 = "3" extends keyof [0, 1, 2] // <= false   ? [0, 1, 2]["3"]   : [0, 1, 2] extends (infer T)[] // <= true   ? T | undefined   : [0, 1, 2] extends readonly (infer T)[]   ? T | undefined   : never;  type Result = 0 | 1 | 2 | undefined; // ? ожидалось `undefined`

Для того, чтобы справиться с этой проблемой, я предлагаю построить табличку с extends для разных типов (назовем эту табличку ExtendsTable) и будем подбирать правильный условный тип, чтобы разграничить массивы и кортежи:

type ExtendsTableRow<T extends any[], E extends any> = {   [K in keyof T]: E extends T[K] ? true : false; };  type ExtendsTable<T extends any[]> = {   [K in keyof T]: ExtendsTableRow<T, T[K]>; };

Возьмем 4 разных типа:

  1. [0]

  2. number[]

  3. readonly number[]

  4. any[]

type Result = ExtendsTable<[[0], number[], readonly number[], any[]]>;

Для лучшего отображения нарисую табличку, чтобы было понятно, что происходит:

[0]

number[]

readonly number[]

any[]

[0]

​+

​+

​+

​+

number[]

​-

​+

​+

​+

readonly number[]

​-

​-

​+

​-

any[]

​-

​+

​+

​+

Если на пересечении знак плюса ​»+», это значит, что строчка расширяема столбцом. Несколько примеров:

  • [0] extends [0]

  • number[] extends readonly number[]

Соответственно, если на пересечении ​знак минуса»-«, то значит, что строка не расширяется колонкой. Пару примеров:

  • number[] extends [0]

  • readonly number[] extends number[]

Возьмем строку с any[]: для колонки [0] мы видим знак минуса «-«​, когда для остальных типов (столбцов) – это ​знак плюса «+».

Собственно, мы нашли ответ!

Мы возьмем этот условный тип any[] extends A и применим к GetWithArray:

type GetWithArray<A, K> = K extends []   ? A   : K extends [infer Key, ...infer Rest]   ? any[] extends A     ? // массивы       A extends readonly (infer T)[]       ? GetWithArray<T | undefined, Rest>       : undefined     : // кортежи     Key extends keyof A     ? GetWithArray<A[Key], Rest>     : undefined   : never;
  1. Мы различаем массив от кортежа с помощью условного типа any[] extends A

  2. Для массивов мы используем T | undefined

  3. Для кортежей, мы извлекаем значение, если индекс для этого кортежа существует

  4. В противном случае, мы возвращаем undefined

Если хочется еще раз взглянуть на все текущие изменения, переходите на Playground.

4. Общее решение

На данный момент у нас есть решение для объектов:

type GetWithArray<O, K> = K extends []   ? O   : K extends [infer Key, ...infer Rest]   ? Key extends keyof O     ? GetWithArray<O[Key], Rest>     : Key extends keyof FilterUndefinedAndNull<O>     ? GetWithArray<FilterUndefinedAndNull<O>[Key], Rest> | undefined     : undefined   : never;

и для массивов:

type GetWithArray<A, K> = K extends []   ? A   : K extends [infer Key, ...infer Rest]   ? any[] extends A     ? // arrays       A extends readonly (infer T)[]       ? GetWithArray<T | undefined, Rest>       : undefined     : // tuples     Key extends keyof A     ? GetWithArray<A[Key], Rest>     : undefined   : never;

Определим два вспомогательных типа:  ExtractFromObject и ExtractFromArray, где мы будем извлекать значение, зная, с какой структурой в данный момент работаем:

type ExtractFromObject<   O extends Record<PropertyKey, unknown>,   K > = K extends keyof O   ? O[K]   : K extends keyof FilterUndefinedAndNull<O>   ? FilterUndefinedAndNull<O>[K] | undefined   : undefined;  type ExtractFromArray<A extends readonly any[], K> = any[] extends A   ? A extends readonly (infer T)[]     ? T | undefined     : undefined   : K extends keyof A   ? A[K]   : undefined;

Здесь пришлось добавлять «ограничения» (Generic Constrains):

  1. Для ExtractFromObject – это O extends Record<PropertyKey, unknown>. Это значит, что O должен быть объектом любого вида

  2. Для ExtractFromArray аналогично:  A extends readonly any[] принимает массив любого типа и кортежи

Добавим соответствующие условные типы в GetWithArray и объединим решения:

type GetWithArray<O, K> = K extends []   ? O   : K extends [infer Key, ...infer Rest]   ? O extends Record<PropertyKey, unknown>     ? GetWithArray<ExtractFromObject<O, Key>, Rest>     : O extends readonly any[]     ? GetWithArray<ExtractFromArray<O, Key>, Rest>     : undefined   : never;

Это решение я тоже покрыл тестами. Ссылка на Playground.

5. Связка с JavaScript

Вернемся к решению в JavaScript:

const get = (obj, path) => {   const keys = path.split(".");    return keys.reduce((currentObj, key) => currentObj[key], obj); };

На данный момент мы используем lodash в нашем проекте, где есть функция get. Если вы выглянете на common/object.d.ts в@types/lodash, то немного огорчитесь. Во многих случаях вызов get возвращает any : typescript-lodash-types

Давайте заменим reduce на цикл с for (например for-of), чтобы была возможность сделать ранний выход из цикла с полученным значением, если оно undefined или null:

export function get = (obj, path) => {   const keys = path.split(".");    let currentObj = obj;   for (const key of keys) {     const value = currentObj[key];     if (value === undefined || value === null) {       return undefined;     }      currentObj = value;   }    return currentObj; }

А теперь покроем эту функцию get типами, которые мы получили на предыдущих шагах. Разделим это на два случая:

  1. Тип Get можно использовать тогда и только тогда, когда все ограничения применимы и тип корректно выводится

  2. В случае какой-то ошибки мы используем вторую сигнатуру (например, мы передали число вместо строки в качестве пути)

Чтобы использовать перегрузку, нам нужно использовать функцию с ключевым слово function, а не стрелочные функции:

export function get(obj: Record<string, unknown>, path: string): unknown {   const keys = path.split(".");    let currentObj = obj;   for (const key of keys) {     const value = currentObj[key];     if (value === undefined || value === null) {       return undefined;     }      currentObj = value as Record<string, unknown>;   }    return currentObj; }

Почти готово. Осталось добавить тип Get :

// strict types ? export function get<O, K extends string>(obj: O, path: K): Get<O, K>; // fallback ? export function get(obj: Record<string, unknown>, path: string): unknown {   const keys = path.split(".");    let currentObj = obj;   for (const key of keys) {     const value = currentObj[key];     if (value === undefined || value === null) {       return undefined;     }      currentObj = value as Record<string, unknown>;   }    return currentObj; }

Все вместе я разместил на Codesandbox:

  1. Мы написали функцию get с типами

  2. Мы покрыли типы тестами

  3. Мы покрыли тестами функцию get

Терминогия

Для решения задачи использовались следующие термины из TypeScript:

1. Кортежи представлены в TypeScript 1.3, но вариативный вариант (Variadic Tuple Types) был выпущен в версии 4.0, так что теперь можно использовать spread внутри кортежей

// Кортежи type Digits = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];  type Name =   | [first: string, last: string]   | [first: string, middle: string, last: string];  // Кортежи, называемые "variadic" type AnyArray = readonly any[];  type Merge<T extends AnyArray, U extends AnyArray> = [...T, ...U];  type Result = Merge<[1, 2], [3, 4]>; //   ^? [1, 2, 3, 4] ?

2. Условные типы (Conditional types) доступны с версии TypeScript 2.8

type FilterUndefined<T> = T extends undefined ? never : T;

3. Ключевое слово infer  в условных типах, которые были представлены в TypeScript 2.8

type AnyFunction = (...args: any) => any;  type MyParameters<TFunction extends AnyFunction> = TFunction extends (   ...args: infer TParameters ) => any   ? TParameters   : never;

4. Рекурсивные условные типы (Recursive conditional types) появились с версии TypeScript 4.1

type ElementType<T> = T extends ReadonlyArray<infer U> ? ElementType<U> : T;

5. Шаблоны для строчных литералов (Template Literal types) также появились с версии TypeScript 4.1

type Fruit = "lemon" | "orange" | "apple"; type Quantity = 1 | 2 | 3;  type ShoppingList = `${Quantity} ${Fruit}`; //   ^? '1 lemon' | '1 orange' | '1 apple' | ... | '3 lemon' | '3 orange' | '3 apple'

6. Ограничения для дженериков (обобщений?) (Generic Constrains)

type Length<T extends { length: number }> = T["length"];

7. Перегрузка функций (Function Overloads)

function get<O, K extends string>(obj: O, path: K): Get<O, K>; function get(obj: Record<string, unknown>, path: string): unknown {   // body }

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


ссылка на оригинал статьи https://habr.com/ru/articles/808015/


Комментарии

Добавить комментарий

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