Задача, которая встречается почти в каждом проекте - это создание параметризированных строк. Чаще всего подобные конструкции можно встретить роутерах, но применение можно найти во многих других местах. Обычно это выглядит примерно так:
const postURL = template('{host}/posts/{id}');
const url = postURL({ host: 'http://example.com', id: 1 });
// url === 'http://example.com/posts/id'
Реализовать подобное API не представляет большой сложности. Разбиваем строку с помощью регулярного выражения, подставляя реальные значения вместо переменных.
// создаем regexp для разбиения
const buildSeparatorVarRx = (start: string, end: string) => new RegExp(`${start}([^${start + end}]+)${end}`);
// в качестве разделителя используем скобки
const rx = buildSeparatorVarRx('\\{', '\\}');
export function template<T extends string>(tmpl: T) {
const array = tmpl.split(rx);
// TTemplateFunction - ???
const fn: TTemplateFunction<T> = args => array.map((item, i) => (i % 2 ? (args as Record<string, string | number>)[item] : item)).join('') as any;
return fn;
}
Данная реализация отвечает на все наши запросы, но остается под вопросом что такое TTemplateFunction
?
Он конструирует из литеральной строки (string literal types) T
тип-функцию, которой на вход передается объект, ключи которого должны соответствовать переменным из типа T
. Возвращаемое значение будет литеральной строкой с подставленными значениями из аргументов вместо переменных из T
.
type TFn = TTemplateFunction<'{host}/posts/{id}'>;
// мы хотим получить подобный результат
type TFn = <THost extends string | number, TId extends string | number>(obj: {
host: THost;
id: TId;
}) => `${THost}/posts/${TId}`;
Давайте реализуем TTemplateFunction
. Для этого нам сначала понадобится разбить строку на токены (строки и переменные) и реализуем это через TSplitter<one/{two}/three> === ['one/', { var: 'two' }, '/three']
. Здесь c помощью THead
мы отрезаем строку до переменной, далее извлекаем саму переменную TVar
между разделителями TSepStart
и TSepEnd
. И в конце сохраняем оставшуюся строку в TTail
. Используя эту информацию сохраняем THead
и TVar
в массив и продолжаем рекурсивно парсить TTail
.
export type TSplitter<
T extends string,
TSepStart extends string = '{',
TSepEnd extends string = '}'
> = T extends `${infer THead}${TSepStart}${infer TVar}${TSepEnd}${infer TTail}`
? [
...(THead extends '' ? [] : [THead]),
{ var: TVar },
...TSplitter<TTail, TSepStart, TSepEnd>
]
: T extends ''
? []
: [T];
Теперь преобразуем полученный массив из TSplitter
в строку которую будут использовать в качестве возвращаемых значений. TJoin<['one/', { var: 'two' }, '/three'], { two: 2 }> === one/2/three
. Здесь мы рекурсивно обходим массив и для каждого элемента вытаскиваем, либо значение строки, либо значение переменной, которая может быть либо строкой, либо числом и подавляем все это в общую строку.
export type TJoin<
T extends unknown[],
TParams extends {
[K: string]: string | number;
}
> = T extends [infer THead, ...infer TTail]
? `${THead extends string
? THead
: THead extends { var: infer TVar }
? TVar extends string
? TParams[TVar]
: ``
: ``}${TJoin<TTail, TParams>}`
: ``;
С помощью TParamsChunks
отфильтруем переменные из массива TSplitter
.
TParamsChunks<['one/', { var: 'two' }, '/three']> === ['two']
. Здесь также рекурсивно обходим массив и отфильтровываем все строки.
export type TParamsChunks<T extends unknown[]> = T extends [
infer THead,
...infer TTail
]
? [
...(THead extends { var: infer TVar } ? [TVar] : []),
...TParamsChunks<TTail>
]
: [];
Для дальнейшего преобразования массива переменных в объект используем TParamsObject
. TParamsObject<['two']> === { two: string | number }
export type TParamsObject<T extends string[]> = {
[K in T[number]]: string | number;
};
И напишем хелпер TIsEmpty
для пустого массива и для подобного случая будем использовать тип void
, чтобы при таком условии не передавать аргументы.
export type TIsEmpty<T extends unknown[], TResult, TEmpty> = T extends [
infer A,
...infer B
]
? TResult
: TEmpty;
// TIsEmpty<[one], 1, void> === 1
// TIsEmpty<[], 1, void> === void;
И теперь объединим все в единый тип TTemplateFunction
. Для простоты мы ввели TTemplateFunctionInner
, чтобы разбить все на блоки.
TSplitLocal
- разбиваем на токены.
TParamsChunksLocal
- создаем массив с именами переменных
TParams
- создаем объект из массива.
И в итоге композируем все в финальный тип-функцию и перезаворачиваем в TTemplateFunction
чтобы скрыть все ненужные подробности.
type TTemplateFunctionInner<
V extends string,
TSepStart extends string = '{',
TSepEnd extends string = '}',
TSplitLocal extends unknown[] = TSplitter<V, TSepStart, TSepEnd>,
TParamsChunksLocal extends string[] = TParamsChunks<TSplitLocal>,
TParams extends {
[K: string]: string | number;
} = TParamsObject<TParamsChunksLocal>
> = <TArgs extends TParams>(
args: TIsEmpty<TParamsChunksLocal, TArgs, void>
) => TJoin<TSplitLocal, TArgs>;
/**
* type for template function
*/
export type TTemplateFunction<
T extends string,
TSepStart extends string = '{',
TSepEnd extends string = '}'
> = TTemplateFunctionInner<T, TSepStart, TSepEnd>;