typescript - Aplicarea inteligentă a tipului parametrului pe baza obiectului de configurare
Nu sunt sigur cum să numesc asta, așa că vă rog să aveți răbdare cu mine în timp ce o descriu.
Am trei obiecte de configurare:
const configA = {
type: 'A' as const,
getPath: (query: { foo: string }) => `/${query.foo}`
}
const configB = {
type: 'B' as const,
getPath: (query: {}) => `/path/b`
}
const configC = {
type: 'C' as const,
getPath: (query: {bar: number}) => `/c/${query.bar}`
}
Le combin pe acestea într-un singur fișier principal astfel:
const CONFIGS = {
[configA.type]: configA,
[configB.type]: configB,
[configC.type]: configC
}
Acum vreau să scriu o funcție ale cărei argumente respectă configurațiile de mai sus. Astfel încât siguranța tipurilor să fie asigurată. Deci, cu cele de mai sus, dacă aș face asta:
add({
type: 'C',
query: { foo: true }
})
Typescript ar trebui să mă avertizeze că dacă type este C, atunci obiectul query de aici este invalid. Totuși, nu mă avertizează.
Am încercat asta:
function add(inputs: {
type: (typeof CONFIGS)[keyof typeof CONFIGS]['type'],
query: Parameters<(typeof CONFIGS)[keyof typeof CONFIGS]['getPath']>[0]
}) {
const config = CONFIGS[inputs.type];
if (!config) {
throw new Error('Config not found');
}
const url = 'mysite.blah' + config.getPath(inputs.query); // inputs.query is getting Typescript warning
console.log('url:', url);
}
Totuși, nu funcționează și are problema suplimentară că inputs.query pe linia unde fac config.getPath(inputs.query) generează această eroare de Typescript:
Argumentul de tip '{} | { foo: string; } | { bar: number; }' nu poate fi atribuit parametrului de tip '{ foo: string; } & { bar: number; }'.
Iată codul meu complet: TypeScript Playground
Cum se numește această tehnică? Este posibil să reparăm asta? Mulțumesc.
Răspuns #1
Semnătura apelului pentru add nu este sigură din punct de vedere al tipului deoarece proprietățile type și query ale parametrului inputs sunt independente una de cealaltă. Ambele sunt tipuri union, care ar putea fi fiecare orice dintre cele (trei) valori posibile. Ai putea rezolva aceasta făcând ca tipul lui inputs să fie un singur union, astfel încât type și query să fie corelate între ele:
type Inputs = {
[P in keyof typeof CONFIGS]: {
type: P,
query: Parameters<typeof CONFIGS[P]["getPath"]>[0]
}
}[keyof typeof CONFIGS]
/* type Inputs =
{ type: "A"; query: { foo: string; }; } |
{ type: "B"; query: {}; } |
{ type: "C"; query: { bar: number; }; } */
declare function add(inputs: Inputs): void;
add({ type: 'C', query: { foo: true } }); // error!
add({ type: 'C', query: { bar: 123 } }); // okay
Asta rezolvă problema de partea apelantului. Din păcate, implementarea are practic aceeași eroare:
function add(inputs: Inputs) {
const config = CONFIGS[inputs.type];
if (!config) {
throw new Error('Config not found');
}
const url = 'mysite.blah' + config.getPath(inputs.query); // error!
console.log('url:', url);
};
Asta pentru că TypeScript nu are suport direct pentru ceea ce eu numesc „unions corelate”, așa cum este descris în microsoft/TypeScript#30581. Când compilatorul verifică tipurile într-un singur bloc de cod, cum ar fi corpul funcției add(), o face o singură dată. Și doar din tipurile expresiilor din corp, nu există suficiente informații pentru a verifica dacă ceea ce faci este sigur. Da, inputs trebuie să fie de tipul corect, dar când încerci să îl folosești de două ori, ca în CONFIGS[inputs.type].getPath(inputs.query), compilatorul nu poate urmări firul. Dacă ar putea analiza acel cod de trei ori, o dată pentru fiecare restrângere a union-ului, ar funcționa. Dar poate face acest lucru doar o dată.
Abordarea recomandată pentru a gestiona acest lucru este descrisă în microsoft/TypeScript#47109 și implică refactorizarea astfel încât, în loc de uniuni, operațiunile să fie exprimate în termeni de tip simplu cheie-valoare și indexuri generice în acel tip și în tipuri mapate peste acel tip. Scopul este să se facă astfel încât CONFIGS[input.type].getPath să fie văzut ca o singură funcție (nu o uniune) al cărei parametru este același tip generic ca cel al input.query.
Refactorizarea ar putea arăta astfel:
Mai întâi să redenumim CONFIGS la o parte, deoarece trebuie să-i schimbăm tipul:
const _CONFIGS = {
[configA.type]: configA,
[configB.type]: configB,
[configC.type]: configC
}
Apoi îl vom folosi pentru a construi tipul nostru simplu de pereche cheie-valoare:
type QueryMap = { [K in keyof typeof _CONFIGS]:
Parameters<typeof _CONFIGS[K]["getPath"]>[0]
}
/* type QueryMap = {
A: { foo: string; };
B: {};
C: { bar: number;};
} */
Vom folosi tipul QueryMap ca bază pentru relațiile implicate aici. Ai fi putut începe cu acesta ca un tip scris manual și să definești CONFIGS în termeni de el, dar aceasta este mai ușor de scalat, deoarece se va actualiza automat dacă adaugi membri.
Acum vom rescrie tipul CONFIGS ca un tip mapat pe QueryMap:
type Configs = { [K in keyof QueryMap]: { type: K, getPath: (query: QueryMap[K]) => string } };
Și spune că CONFIGS este de acel tip:
const CONFIGS: Configs = _CONFIGS; // okay
Atribuirea reușește, astfel încât compilatorul poate vedea echivalența între typeof _CONFIGS și Configs. Și acum putem rescrie add() pentru a fi o funcție generică în K extends keyof Configs:
function add<K extends keyof Configs>(inputs: { type: K, query: QueryMap[K] }) {
const config = CONFIGS[inputs.type];
if (!config) {
throw new Error('Config not found');
}
const url = 'mysite.blah' + config.getPath(inputs.query); // okay
console.log('url:', url);
}
Acum funcționează. Este subtil, pentru că dacă înlocuiești CONFIGS cu _CONFIGS în prima linie a funcției, eroarea va reveni. Da, tipurile lor sunt echivalente, dar nu sunt tratate identic de către compilator. Tipul Configs este exprimat ca un tip mapat peste QueryMap, iar inputs are de asemenea un tip care depinde de QueryMap, și această corespondență poate fi folosită pentru verificarea tipului funcției. Tipul _CONFIGS nu este exprimat în raport cu QueryMap, așa că compilatorul nu vede legătura generică dintre acesta și inputs.
Oh și apelanții primesc în continuare comportamentul pe care îl doreai, de asemenea:
add({ type: 'C', query: { foo: true } }); // error!
add({ type: 'C', query: { bar: 123 } }); // okay
Iată-l. Această refactorizare este aproape de siguranța tipurilor verificată de compilator pentru implementarea ta add(), cât de aproape poți ajunge. Nu sunt sigur că merită neapărat; dacă nu-ți pasă ca compilatorul să verifice codul în implementare, ai putea folosi pur și simplu o aserțiune de tip pentru a-l face să tacă:
function add(inputs: Inputs) {
const config = CONFIGS[inputs.type];
if (!config) {
throw new Error('Config not found');
}
const url = 'mysite.blah' + config.getPath(inputs.query as any); // okay
console.log('url:', url);
};
ceea ce este mult mai ușor de făcut. Totul depinde de cazurile tale de utilizare.
Link de teren de joacă către cod
