#typescript
#typescript
Вопрос:
Я пытаюсь создать функцию сопоставления с образцом для typescript, которая работает с различаемым объединением.
Например:
export type WatcherEvent =
| { code: "START" }
| {
code: "BUNDLE_END";
duration: number;
result: "good" | "bad";
}
| { code: "ERROR"; error: Error };
Я хочу иметь возможность вводить match
функцию, которая выглядит следующим образом:
match("code")({
START: () => ({ type: "START" } as const),
ERROR: ({ error }) => ({ type: "ERROR", error }),
BUNDLE_END: ({ duration, result }) => ({
type: "UPDATE",
duration,
result
})
})({ code: "ERROR", error: new Error("foo") });
Пока у меня есть это
export type NonTagType<A, K extends keyof A, Type extends string> = Omit<
Extract<A, { [k in K]: Type }>,
K
>;
type Matcher<Tag extends string, A extends { [k in Tag]: string }> = {
[K in A[Tag]]: (v: NonTagType<A, Tag, K>) => unknown;
};
export const match = <Tag extends string>(tag: Tag) => <
A extends { [k in Tag]: string }
>(
matcher: Matcher<Tag, A>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) => <R extends any>(v: A): R =>
(matcher as any)[v[tag]](v);
Но я не знаю, как ввести возвращаемый тип для каждого случая
На данный момент в каждом случае параметры вводятся правильно, но возвращаемый тип неизвестен, поэтому, если мы возьмем этот случай
ERROR: ({ error }) => ({ type: "ERROR", error }), // return type is not inferred presently
тогда возвращаемый тип каждой функции, подобной случаю, неизвестен, как и возвращаемый тип match
самой функции:
Вот codesandbox .
Комментарии:
1. В моей книге сопоставление с образцом состоит из 1) всех конструкций / альтернатив case и их обработчиков 2) текущего аргумента шаблона. Я не совсем понимаю, каков ваш третий аргумент
v: A
.2. @bela53
v
— это шаблон. Но поскольку технически невозможно сопоставить шаблон объекта в javascript, сопоставление вместо этого выполняется по значению внутри этого «тега» шаблона.
Ответ №1:
На мой взгляд, есть два подхода, которые вы можете использовать для этого.
1. Тип ввода известен заранее
Если вы хотите обеспечить, чтобы инициализация конечной функции принимала определенный тип, то этот тип должен быть известен заранее:
// Other types omitted for clarity:
const match = <T>(tag) => (transforms) => (source) => ...
В этом примере вы указываете T
во время первого вызова со следующими ограничениями типа как следствие:
tag
должен быть ключомT
transforms
должен быть объектом с ключами для всех значенийT[typeof tag]
source
должен иметь типT
Другими словами, тип, который заменяет T
, определяет значения, которые tag
transforms
и source
могут иметь. Это кажется мне наиболее простым и понятным, и я попытаюсь привести пример реализации для этого. Но прежде чем я это сделаю, есть также подход 2:
2. тип ввода выводится из последнего вызова
Если вы хотите иметь больше гибкости в типе for source
на основе значений для tag
и transforms
, тогда тип может быть задан при последнем вызове или выведен из него:
const match = (tag) => (transforms) => <T>(source) => ...
В этом примере T
создается экземпляр во время последнего вызова, как следствие, со следующими ограничениями типа:
source
должен иметь ключtag
typeof source[tag]
должен быть объединением не более всехtransforms
ключей, т.Е.keyof typeof transforms
. Другими словами,(typeof source[tag]) extends (keyof typeof transforms)
всегда должно быть true для данногоsource
.
Таким образом, вы не ограничены конкретной заменой T
, но T
в конечном итоге может быть любым типом, который удовлетворяет вышеуказанным ограничениям. Основным недостатком этого подхода является то, что проверка типа будет незначительной transforms
, поскольку он может иметь любую форму. Совместимость между tag
, transforms
и source
может быть проверена только после последнего вызова, что значительно усложняет понимание, и любые ошибки проверки типов, вероятно, будут довольно загадочными. Поэтому я использую первый подход, приведенный ниже (кроме того, этот подход довольно сложно обойти 😉
Поскольку мы указываем тип заранее, это будет слот типа в первой функции. Для совместимости с другими частями функции она должна расширяться Record<string, any>
:
const match = <T extends Record<string, any>>(tag: keyof T) => ...
Для вашего примера мы бы назвали это так:
const result = match<WatcherEvent>('code') (...) (...)
Нам понадобится тип
tag
для дальнейшего построения функции, но для его параметризации, например, сK
помощью, это приведет к неудобному API, где вам придется писать ключ буквально дважды:
const match = <T extends Record<string, any>, K extends keyof T>(tag: K) const result = match<WatcherEvent, 'code'>('code') (...) (...)
Поэтому вместо этого я иду на компромисс, где я буду писать
typeof tag
вместоK
того, чтобы идти дальше по строке.
Следующая функция, которая принимает transforms
, давайте использовать параметр type U
для хранения его типа:
const match = <T extends Record<string, any>>(tag: keyof T) => (
<U extends ?>(transforms: U) => ...
)
Ограничение типа для U
— это то, где это становится сложным. Таким U
образом, должен быть объект с одним ключом для каждого значения T[typeof tag]
, каждый ключ содержит функцию, которая преобразует a WatcherEvent
во все, что вам нравится ( any
) . Но не просто любой WatcherEvent
, а именно тот, для которого в качестве значения указан соответствующий ключ code
. Для ввода этого нам понадобится вспомогательный тип, который сужает WatcherEvent
объединение до одного единственного члена. Обобщая это поведение, я пришел к следующему:
// If T extends an object of shape { K: V }, add it to the output:
type Matching<T, K extends keyof T, V> = T extends Record<K, V> ? T : never
// So that Matching<WatcherEvent, 'code', 'ERROR'> == { code: "ERROR"; error: Error }
С помощью этого помощника мы можем написать вторую функцию и заполнить ограничение типа для U
следующего:
const match = <T extends Record<string, any>>(tag: keyof T) => (
<U extends { [V in T[typeof tag]]: (input: Matching<T, typeof tag, V>) => any }>(transforms: U) => ...
)
Это ограничение гарантирует, что все входные сигнатуры функции transforms
соответствуют предполагаемому члену T
объединения (или WatcherEvent
в вашем примере).
Обратите внимание, что возвращаемый тип
any
здесь не ослабляет возвращаемый тип в конечном счете (потому что мы можем сделать вывод об этом позже). Это просто означает, что вы можете возвращать все, что хотите, из функцийtransforms
.
Теперь мы подошли к последней функции — той, которая принимает окончательную source
, и ее входная сигнатура довольно проста; S
должна расширяться T
, где T
было WatcherEvent
в вашем примере, и S
будет точной const
формой данного объекта. Возвращаемый тип использует ReturnType
помощник стандартной библиотеки typescript для определения возвращаемого типа функции сопоставления. Фактическая реализация функции эквивалентна вашему собственному примеру:
const match = <T extends Record<string, any>>(tag: keyof T) => (
<U extends { [V in T[typeof tag]]: (input: Matching<T, typeof tag, V>) => any }>(transforms: U) => (
<S extends T>(source: S): ReturnType<U[S[typeof tag]]> => (
transforms[source[tag]](source)
)
)
)
Это должно быть так! Теперь мы могли бы вызвать match (...) (...)
, чтобы получить функцию f
, которую мы можем протестировать на разных входных данных:
// Disobeying some common style rules for clarity here ;)
const f = match<WatcherEvent>("code") ({
START : () => ({ type: "START" }),
ERROR : ({ error }) => ({ type: "ERROR", error }),
BUNDLE_END : ({ duration, result }) => ({ type: "UPDATE", duration, result }),
})
И попытка сделать это с разными WatcherEvent
членами дает следующий результат:
const x = f({ code: 'START' }) // { type: string; }
const y = f({ code: 'BUNDLE_END', duration: 100, result: 'good' }) // { type: string; duration: number; result: "good" | "bad"; }
const z = f({ code: "ERROR", error: new Error("foo") }) // { type: string; error: Error; }
Обратите внимание, что когда вы указываете f
WatcherEvent
(тип объединения) вместо буквального значения, возвращаемый тип также будет объединением всех возвращаемых значений в преобразованиях, что мне кажется правильным поведением:
const input: WatcherEvent = { code: 'START' }
const output = f(input)
// typeof output == { type: string; }
// | { type: string; duration: number; result: "good" | "bad"; }
// | { type: string; error: Error; }
Наконец, если вам нужны конкретные строковые литералы в возвращаемых типах вместо универсального string
типа, вы можете сделать это, просто изменив функции, которые вы определяете как transforms
. Например, вы можете определить дополнительный тип объединения или использовать « as const
аннотации в реализациях функций.
Вот ссылка на TSPlayground, я надеюсь, это то, что вы ищете!
Ответ №2:
Я думаю, что это выполняет то, что вы хотите, однако я не думаю, что ваша попытка принудительного применения «NonTagType» s возможна, и я не уверен, почему это было бы даже желательно.
type Matcher<Types extends string = string, V = any> = {
[K in Types]: (v: V) => unknown;
};
export const match = <Tag extends string>(tag: Tag) => <
M extends Matcher
>(
matcher: M
) => <V extends {[K in Tag]: keyof M }>(v: V) =>
matcher[v[tag]](v) as ReturnType<M[V[Tag]]>;
const result = match("code")({
START: () => ({ type: "START" } as const),
ERROR: ({ error }: { error: Error }) => ({ type: "ERROR", error } as const),
BUNDLE_END: ({ duration, result }: { duration: number, result: "good" | "bad" }) => ({
type: "UPDATE",
duration,
result
} as const)
})({ code: "ERROR", error: new Error("foo") })
Ответ №3:
Вот довольно плотное решение, которое — если я не понял неправильно — предоставляет все, что вы хотите:
export type WatcherEvent =
| {
code: 'START'
//tag: 'S'
}
| {
code: 'BUNDLE_END'
// tag: 'B'
duration: number
result: 'good' | 'bad'
}
| {
code: 'ERROR'
// tag: 'E';
error: Error
}
const match = <U extends { [K in string]: any }>(u: U) => <
T extends keyof Extract<U, { [K in keyof U]: string }> amp; keyof U
>(
tag: T
) => <H extends { [K in U[T]]: (v: Omit<Extract<U, { [P in T]: K }>, T>) => any }>(h: H) =>
h[u[tag]](u as any) as ReturnType<H[U[T]]>
const e: WatcherEvent = {} as any // unknown event
const e2: WatcherEvent = { code: 'ERROR', error: new Error('?') } // known event
// 'code' (or 'tag' if you uncomment tag:)
const result = match(e)('code')({
// all START, BUNDLE_END and ERROR must be specified, but nothing more
START: v => ({ type: 'S' as const }), // v is never; v.code was removed
BUNDLE_END: v => ({
type: 'BE' as const,
dur: v.duration,
result: v.result
}),
ERROR: () => ({ type: 'E' } as const)
})
result.type // 'E' | 'S' | 'BE'
const r2 = match(e2)('code')({
// since e2 is a known event START and BUNDLE_END cannot be specified
ERROR: () => null
})
r2 === null // true
Самым большим недостатком вышеупомянутого решения является то, что цель / данные должны быть известны заранее, чтобы вывести другие типы (функции тегов и сопоставления). Это можно было бы улучшить, явно указав целевой тип, например:
type Matcher<U extends { [K in string]: any }> = <
T extends keyof Extract<U, { [K in keyof U]: string }> amp; keyof U
>(
tag: T
) => <
H extends { [K in U[T]]: (v: Omit<Extract<U, { [P in T]: K }>, T>) => any }
>(
h: H
) => (u: U) => ReturnType<H[U[T]]>;
const matcher_any = <U extends { [K in string]: any }>(t: any) => (h: any) => (e: any) => h[e[t]](e as any) as Matcher<U>;
const createMatcher = <U extends { [K in string]: any }>() =>
matcher_any as Matcher<U>;
const matcher = createMatcher<WatcherEvent>()("code")({
// all START, BUNDLE_END and ERROR must be specified, but nothing more
START: (v) => ({ type: "S" as const }), // v is never; v.code was removed
BUNDLE_END: (v) => ({
type: "BE" as const,
dur: v.duration,
result: v.result
}),
ERROR: () => ({ type: "E" } as const)
})
console.log(matcher(e).type)