typescript - Hur typer man korrekt detta slags mönster?


Jag har skrivit lite kod som följer den här typen av mönster:

  • En endpoint tar emot viss data som kan vara en av flera fördefinierade objekttyper. Till exempel, låt oss säga att vi har tre olika objekt som heter Contact, Connection och Friend. Var och en av dessa objekt har helt olika strukturer, men liknande logik behöver utföras på varje som omvandlar datan till samma typ av output. Så, jag har skrivit tre olika funktioner på detta sätt:
const transformContact = (data: Contact): Record<string, any> => {return {name: "mike", id: 123}};
const transformConnection = (data: Connection): Record<string, any> => {return {name: "alex", id: 124}}
const transformFriend = (data: Friend): Record<string, any> => {return {name: "sarah", id: 125}}

interface Contact {
  name: string;
  phoneNumber: string;
}

interface Connection {
  firstName: string;
  lastName: string;
  cellNumber: string;
}

interface Friend {
  address: string;
  nickname: string;
}

Och sedan har jag en funktion som returnerar en transformfunktion att använda i resten av slutpunkten, så att jag inte behöver göra kontroller baserat på indata för resten av koden:

type ObjectType = 'Contact' | 'Connection' | 'Friend';

export const getDocFunction = (type: ObjectType): ReturnType => {
  switch (type) {
    case 'Contact':
      return transformContact;
    case 'Connection':
      return transformConnection;
    case 'Friend':
      return transformFriend;
  }
}

Jag har gjort ReturnType så här:

type ReturnType = 
((data: Contact) => Record<string, any>) |
((data: Connection) => Record<string, any>) |
((data: Friend) => Record<string, any>)

Nu, på min endpoint, när jag tar emot någon data, vet jag att det antingen är av typen Contact ELLER Connection, ELLER Friend

type Data = Contact | Connection | Friend;

Men när jag kallar getDocFunction, kräver funktionen den returnerar faktiskt en typparameter på detta sätt:

type ActuallyNeeded = Contact & Connection & Friend;

Jag förstår detta på ett sätt, eftersom om koden säger att den mottagna datan bara är en av de tre möjliga typerna, men funktionen bara accepterar en specifik typ, så uppstår fel. Så det är logiskt, men vad jag inte gillar är att datan som typas som en union av alla tre helt enkelt inte är en korrekt representation av datan.

Det fungerar på något sätt för mig eftersom allt jag gör i slutpunktskoden är att bara skicka in datan till transformfunktionen och jag är klar med det. Men om jag måste göra mer i framtiden, då känns det inte riktigt rätt för mig att intellisense säger att datan har egenskaper från alla tre möjliga gränssnitt kombinerade, eftersom den faktiskt bara kommer att följa ett av dessa gränssnitt.

Har du några förslag på hur man kan göra detta mönster renare eller förbättra typningen på något sätt? Dessutom, som en sekundär fråga, har denna typ av mönster ett formellt namn? Om så är fallet skulle jag vilja göra lite research om det.

REDIGERA: Här är en ts-lekplats med korrekt kod:

Svar #1

Det underliggande problemet du har är att du har ett enda "event"-objekt vars body är ett Contact, Connection eller Friend, vilket också innehåller en type-egenskap som är en av stränglitteraltyperna "Contact", "Connection" eller "Friend"... men din datastruktur representerar inte korrelationen mellan dessa. Så vitt kompilatorn vet kan event-body ha typen "Friend" men vara av typen Connection. Det vill säga, den hanterar två okorrelerade unionstyper: (Contact | Connection | Friend) & {type: "Contact" | "Connection" | "Friend"}.

Så när du letar upp en docFunction med body.type får du en av de tre möjliga funktionerna, och när du sedan försöker skicka body som parameter, är kompilatorn orolig för att det inte är säkert. "Tänk om du skickar en Connection till något som visar sig vara transformFriend()?" Det enda som den kan vara säker på skulle vara säkert är om du skickade något som var en Contact och en Connection och en Friend till funktionen, eftersom det då skulle fungera oavsett. Och det är därför felmeddelandet klagar på att det inte kan vara säker på att din Contact | Connection | Friend faktiskt är en Contact & Connection & Friend.

Verkligen, den typen av fel är vanligtvis den sorts sak som får mig att uppmärksamma förekomsten av detta underliggande problem med korrelerade unioner. Vid ett tillfälle skickade jag in ett ärende på microsoft/TypeScript#30581 och bad om ett sätt att hantera dessa som är bättre än att bara ge upp (t.ex. att använda typförsäkringar för att ljuga för kompilatorn).


Så småningom blev microsoft/TypeScript#47109 sammanslaget, vilket bland annat beskriver en refaktoriseringsteknik som använder generics för att få bättre beteende. Idén är att skriva ut typer explicit som avbildade typer över en viss uppsättning nycklar och indexera dem med en av dessa nycklar som en generisk typparameter, så att den operation du vill stödja kan ses som en enkel generisk operation.

I ditt exempel på kodfall skulle jag förmodligen göra det så här:


Först, bygg en typ som representerar en nyckel-värde-mappning av dina data på det enklaste sättet möjligt:

interface DataMap {
  Contact: Contact,
  Connection: Connection,
  Friend: Friend
}

Sedan börja ge typer till saker som uttryckligen avbildas över denna typ:

const docFunction: { [K in keyof DataMap]: (data: DataMap[K]) => Record<string, any> } = {
  Contact: transformContact,
  Connection: transformConnection,
  Friend: transformFriend
}
export const getDocFunction = <K extends keyof DataMap>(type: K) => {
  return docFunction[type];
}

Så getDocFunction tar en type av generisk typ K och returnerar en funktion av motsvarande generisk typ (data: DataMap[K]) => Record<string, any> på ett sätt som fördelar över unioner i K.

Låt oss nu beskriva egenskapen body för eventet som en liknande distribuerad mappad typ:

type Body<K extends keyof DataMap = keyof DataMap> =
  { [P in K]: { type: P } & DataMap[P] }[K];

Du kan förhoppningsvis övertyga dig själv om att till exempel Body<"Contact"> är {type: "Contact"} & Contact, och att Body<keyof DataMap> är en union av de tre typerna.

Slutligen kan vi skriva en generisk processEvent()-funktion som tar en Body<K> och anropar rätt transformfunktion:

function processEvent<K extends keyof DataMap>(event: { body: Body<K> }) {
  const transformFunc = getDocFunction(event.body.type);
  const transformedData = transformFunc(event.body);
  return transformedData;
}

Det fungerar eftersom transformFunc ses som ekvivalent med typen (data: DataMap[K]) => Record<string, any>, medan event.body kan tilldelas DataMap[K], så funktionen är känd för att acceptera event.body, och den kompilerar utan fel.

Och du kan kalla processEvent() med vilken som helst av Body-typerna (eller till och med en union) och det kommer att lyckas:

processEvent({
  body: {
    type: "Contact",
    name: "abc",
    phoneNumber: "123"
  }
})

Lekplatslänk till kod