возвращаемый тип для функции сопоставления с образцом в typescript

#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 во время первого вызова со следующими ограничениями типа как следствие:

  1. tag должен быть ключом T
  2. transforms должен быть объектом с ключами для всех значений T[typeof tag]
  3. source должен иметь тип T

Другими словами, тип, который заменяет T , определяет значения, которые tag transforms и source могут иметь. Это кажется мне наиболее простым и понятным, и я попытаюсь привести пример реализации для этого. Но прежде чем я это сделаю, есть также подход 2:

2. тип ввода выводится из последнего вызова

Если вы хотите иметь больше гибкости в типе for source на основе значений для tag и transforms , тогда тип может быть задан при последнем вызове или выведен из него:

 const match = (tag) => (transforms) => <T>(source) => ...
  

В этом примере T создается экземпляр во время последнего вызова, как следствие, со следующими ограничениями типа:

  1. source должен иметь ключ tag
  2. 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)
  

codesandbox