tip de returnare pentru funcția de potrivire a modelului în TypeScript
Încerc să creez o funcție de potrivire a tiparelor pentru TypeScript care să funcționeze pe o uniune discriminată.
De exemplu:
export type WatcherEvent =
| { code: "START" }
| {
code: "BUNDLE_END";
duration: number;
result: "good" | "bad";
}
| { code: "ERROR"; error: Error };
Vreau să pot scrie o funcție match care arată astfel:
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") });
Până acum am asta
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);
Dar nu știu cum să tastez tipul de returnare pentru fiecare caz
În acest moment, fiecare caz tastează corect parametrii, dar tipul de retur este necunoscut, așa că dacă luăm acest caz
ERROR: ({ error }) => ({ type: "ERROR", error }), // return type is not inferred presently
atunci tipul de retur al fiecărui caz, ca funcție, este necunoscut, la fel ca și tipul de retur al funcției match în sine:
Iată un codesandbox.
Răspuns #1
Din punctul meu de vedere, există două abordări pe care le poți adopta în legătură cu asta.
1. Tipul de intrare este cunoscut în prealabil
Dacă dorești să impui ca inițializarea funcției finale să ia un anumit tip, atunci acel tip trebuie să fie cunoscut în prealabil:
// Other types omitted for clarity:
const match = <T>(tag) => (transforms) => (source) => ...
În acest exemplu specificați T în momentul primei apelări, cu următoarele constrângeri de tip ca o consecință:
tagtrebuie să fie o cheie aTtransformstrebuie să fie un obiect cu chei pentru toate valorile dinT[typeof tag]sourcetrebuie să fie de tipT
Cu alte cuvinte, tipul care substituie T determină valorile pe care tag, transforms și source le pot avea. Acest lucru mi se pare cel mai simplu și mai ușor de înțeles, și voi încerca să dau un exemplu de implementare pentru acesta. Dar înainte de a face asta, există și abordarea 2:
2. tipul de intrare este dedus din ultima apelare
Dacă doriți să aveți mai multă flexibilitate în tipul pentru source pe baza valorilor pentru tag și transforms, atunci tipul poate fi specificat la ultima apelare sau poate fi dedus din aceasta:
const match = (tag) => (transforms) => <T>(source) => ...
În acest exemplu T este instanțiat în momentul ultimei apelări, având următoarele constrângeri de tip ca urmare:
sourcetrebuie să aibă o cheietagtypeof source[tag]trebuie să fie o uniune de cel mult toate cheiletransforms, adicăkeyof typeof transforms. Cu alte cuvinte,(typeof source[tag]) extends (keyof typeof transforms)trebuie să fie întotdeauna adevărat pentru un anumitsource.
În acest fel, nu ești limitat la o substituție specifică a T, dar T ar putea, în cele din urmă, să fie orice tip care satisface constrângerile de mai sus. Un dezavantaj major al acestei abordări este că va exista o verificare limitată a tipurilor pentru transforms, deoarece acesta poate avea orice formă. Compatibilitatea între tag, transforms și source poate fi verificată doar după ultima apelare, ceea ce face lucrurile mult mai greu de înțeles, iar orice erori de verificare a tipurilor vor fi probabil destul de criptice. Prin urmare, aleg prima abordare de mai jos (de asemenea, aceasta este destul de greu de înțeles pentru mine ;)
Deoarece specificăm tipul în avans, acesta va fi un slot de tip în prima funcție. Pentru compatibilitatea cu părțile ulterioare ale funcției, trebuie să extindă Record<string, any>:
const match = <T extends Record<string, any>>(tag: keyof T) => ...
Modul în care am numi acest lucru pentru exemplul tău este:
const result = match<WatcherEvent>('code') (...) (...)
Vom avea nevoie de tipul de
tagpentru a continua construirea funcției, dar parametrizarea acestuia, de exemplu cuK, ar duce la un API stângaci în care trebuie să scrii cheia de două ori literal:const match = <T extends Record<string, any>, K extends keyof T>(tag: K) const result = match<WatcherEvent, 'code'>('code') (...) (...)Așa că, în schimb, optez pentru un compromis unde voi scrie
typeof tagîn loc deKmai târziu.
Următorul pas este funcția care preia transforms, să folosim parametrul de tip U pentru a-i păstra tipul:
const match = <T extends Record<string, any>>(tag: keyof T) => (
<U extends ?>(transforms: U) => ...
)
Constrângerea de tip pentru U este locul unde devine complicat. Deci U trebuie să fie un obiect cu o cheie pentru fiecare valoare a T[typeof tag], fiecare cheie reținând o funcție care transformă un WatcherEvent în orice dorești (any). Dar nu orice WatcherEvent, ci specific pe cel care are cheia respectivă ca valoare pentru code. Pentru a tipa acest lucru, vom avea nevoie de un tip ajutător care să restrângă uniunea WatcherEvent la un singur membru. Generalizând acest comportament, am venit cu următoarea idee:
// 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 }
Cu acest ajutor putem scrie a doua funcție și completa constrângerea de tip pentru U astfel:
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) => ...
)
Această constrângere se va asigura că toate semnăturile de intrare ale funcțiilor din transforms se potrivesc cu membrul dedus al uniunii T (sau WatcherEvent în exemplul tău).
Rețineți că tipul de retur
anyaici nu slăbește tipul de retur în cele din urmă (deoarece putem deduce acest lucru mai târziu). Înseamnă pur și simplu că sunteți liber să returnați orice doriți din funcțiile dintransforms.
Acum am ajuns la ultima funcție -- cea care preia source-ul final, iar semnătura sa de intrare este destul de simplă; S trebuie să extindă T, unde T a fost WatcherEvent în exemplul tău, iar S va fi forma exactă const a obiectului dat. Tipul de returnare folosește ajutorul ReturnType din biblioteca standard TypeScript pentru a deduce tipul de returnare al funcției corespunzătoare. Implementarea efectivă a funcției este echivalentă cu exemplul tă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) => (
<S extends T>(source: S): ReturnType<U[S[typeof tag]]> => (
transforms[source[tag]](source)
)
)
)
Asta ar trebui să fie tot! Acum am putea apela match (...) (...) pentru a obține o funcție f pe care o putem testa cu diferite intrări:
// 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 }),
})
Și încercând acest lucru cu diferiții membri WatcherEvent oferă următorul rezultat:
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; }
Rețineți că atunci când îi oferiți lui f un WatcherEvent (tip uniune) în loc de o valoare literală, tipul returnat va fi de asemenea uniunea tuturor valorilor returnate în transformări, ceea ce mi se pare comportamentul corect:
const input: WatcherEvent = { code: 'START' }
const output = f(input)
// typeof output == { type: string; }
// | { type: string; duration: number; result: "good" | "bad"; }
// | { type: string; error: Error; }
În cele din urmă, dacă ai nevoie de literali de tip șir specifici în tipurile de returnare în locul tipului generic string, poți face asta doar modificând funcțiile pe care le definești ca transforms. De exemplu, ai putea defini un tip de uniune suplimentar sau folosi adnotări as const în implementările funcțiilor.
Iată un link către TSPlayground, sper că asta este ceea ce cauți!
Răspunsul #2
Cred că acest lucru realizează ceea ce vrei, totuși nu cred că încercarea ta de a impune „NonTagType”-uri este posibilă și nu sunt sigur de ce ar fi chiar de dorit.
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") })
