Всем привет! ?
⚠️ Возрождаю статью, мною написанную в 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;
Немного прокомментирую:
K extends [infer Key, ...infer Rest]
проверяет, что у нас есть хотя бы один элемент в кортеже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;
Сначала проверим, что ключ существует в объекте с
undefined
и/илиnull
В противном случае, его нет (то есть мы возвращаем
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 разных типа:
[0]
number[]
readonly number[]
any[]
type Result = ExtendsTable<[[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;
Мы различаем массив от кортежа с помощью условного типа
any[] extends A
Для массивов мы используем
T | undefined
Для кортежей, мы извлекаем значение, если индекс для этого кортежа существует
В противном случае, мы возвращаем
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):
Для
ExtractFromObject
– этоO extends Record<PropertyKey, unknown>
. Это значит, чтоO
должен быть объектом любого видаДля
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
типами, которые мы получили на предыдущих шагах. Разделим это на два случая:
Тип
Get
можно использовать тогда и только тогда, когда все ограничения применимы и тип корректно выводитсяВ случае какой-то ошибки мы используем вторую сигнатуру (например, мы передали число вместо строки в качестве пути)
Чтобы использовать перегрузку, нам нужно использовать функцию с ключевым слово 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:
Терминогия
Для решения задачи использовались следующие термины из 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
}
Всем спасибо за внимание. Если есть пожелания, пишите в комментарии. Всем хорошего вечера и выходных.