Tip de returnare condiționat în TypeScript folosind un parametru obiect și valori implicite


Încerc să scriu o funcție unde tipul de returnare este specificat de un șir, dar cu câteva răsuciri suplimentare incluse:

  • Parametrul este într-un obiect
  • Parametrul este opțional
  • Obiectul este opțional

Se poate face asta?

Iată o versiune simplificată a ceea ce încerc să fac. În exemplele de mai jos, arăt diferitele moduri în care vreau să pot apela funcția mea fn() și să fac ca TypeScript să deducă corect tipul de returnare:

interface NumberWrapper {
  type: "my_number";
  value: number;
}

const x1: number        = fn(1, { returnType: "bare" });
const x2: NumberWrapper = fn(1, { returnType: "wrapped" });
const x3: void          = fn(1, { returnType: "none" });
const x4: number        = fn(1, {});
const x5: number        = fn(1);

Pentru x1, x2, x3, funcția este apelată cu o valoare pentru returnType.

Pentru x4, nu este furnizată nicio valoare pentru returnType, și vreau ca aceasta să fie implicit "bare". În mod similar, pentru x5, obiectul nu este furnizat deloc, și vreau să se comporte de parcă returnType ar fi "bare".

(S-ar putea să te întrebi de ce mă deranjez să pun returnType într-un obiect, dat fiind că este doar un parametru. În cazul meu real de utilizare, există și alți parametri în obiect.)

Până acum am reușit să fac să funcționeze x1, x2 și x3, dar nu x4 sau x5. Iată ce am:

type ReturnMap<T extends "bare" | "wrapped" | "none"> = T extends "bare"
  ? number
  : T extends "wrapped"
  ? NumberWrapper
  : void;

function fn<T extends "bare" | "wrapped" | "none">(
  x: number,
  { returnType }: { returnType: T }
): ReturnMap<T> {
  if (returnType === "bare") {
    return x as ReturnMap<T>;
  } else if (returnType === "wrapped") {
    return { type: "my_number", value: x } as ReturnMap<T>;
  } else {
    return undefined as ReturnMap<T>;
  }
}

Un lucru care nu-mi place este că fiecare declarație return are forma return x as ReturnMap<T> la final. Aș vrea să nu fac asta pentru că as face să piardă o parte din siguranța tipului.

Dar problema mai mare este că acest lucru nu funcționează pentru x4 și x5. Am încercat să folosesc valori implicite în diferite moduri, dar nu am reușit să fac să funcționeze.

Răspuns #1

O soluție rapidă este să faci tipul generic întregul argument și nu doar proprietatea returnType. Apoi este doar un lanț de operatori condiționali (mai lung) pentru a obține tipurile dorite - deși asta necesită în continuare urâtele afirmații as.

type Arg = undefined | { returnType?: "bare" | "wrapped" | "none" };
type ReturnMap<T extends Arg> =
  T extends { returnType: 'wrapped' }
    ? NumberWrapper
    : T extends { returnType: 'none' }
      ? void
      : number;

function fn<T extends Arg>(
  x: number,
  arg?: T
): ReturnMap<T> {
  if (!arg || !('returnType' in arg) || arg.returnType === 'bare') {
    return x as ReturnMap<T>;
  } else if (arg.returnType === 'wrapped') {
    return { type: "my_number", value: x } as ReturnMap<T>;
  } else {
    return undefined as ReturnMap<T>;
  }
}

const x1: number = fn(1, { returnType: "bare" });
const x2: NumberWrapper = fn(1, { returnType: "wrapped" });
const x3: void = fn(1, { returnType: "none" });
const x4: number = fn(1, {});
const x5: number = fn(1);

Răspunsul #2

Iată cum aș scrie probabil funcția ta fn():

interface ReturnMap {
  bare: number,
  wrapped: NumberWrapper,
  none: void
}

function fn<K extends keyof ReturnMap = "bare">(
  x: number,
  { returnType = "bare" as K }: { returnType?: K } = { returnType: "bare" as K }
): ReturnMap[K] {
  return {
    get bare() { return x },
    get wrapped() { return { type: "my_number" as const, value: x } },
    get none() { return undefined }
  }[returnType]

}

Explicații :


Pentru problema ta cu „as ReturnMap<T>”, problema este că compilatorul nu poate verifica dacă implementarea ta satisface tipul de returnare. Tipul ReturnMap<T> este un tip condițional care depinde de un parametru generic de tip T. În astfel de cazuri, compilatorul spune „Nu știu ce este T, așa că nu știu ce este ReturnMap<T>”. Implementarea ta testează arg, dar compilatorul nu poate folosi aceste teste pentru a restrânge parametrul de tip T. Aceasta este o caracteristică cunoscută lipsă în TypeScript; vezi microsoft/TypeScript#33912 pentru mai multe informații.

În general, aș recomanda refactorizarea către ceva ce compilatorul poate înțelege, cum ar fi indexarea într-un obiect folosind o cheie generică.

De aceea, ReturnMap este un tip de mapare real, cu chei care corespund valorilor tale returnType, iar valorile proprietăților sale sunt tipurile de returnare asociate. Apoi, fn() poate fi generic în K extends keyof ReturnMap și vei returna ReturnMap[K] prin căutarea proprietății returnType a unui obiect de tip ReturnMap

Rețineți că am folosit metode getter astfel încât obiectul de tip ReturnMap să nu fie nevoit să calculeze rezultatul pentru toate intrările posibile în prealabil.


Pentru problema „implicită” cu cazurile x4 și x5, doriți comportamentul pe care îl obțineți din următorul JavaScript folosind atât un parametru de funcție implicit, cât și un implicit în atribuirea prin destructurare:

function fn(x, {returnType = "bare"} = {returnType: "bare"}) {}

Aceasta are exact comportamentul de rulare pe care îl cauți. Din păcate, când îi dai tipizări, vei avea nevoie de unele afirmații de tip pentru a convinge compilatorul să le accepte. Asta se întâmplă pentru că genericele și parametrii impliciți nu se potrivesc foarte bine împreună în TypeScript. Vezi această întrebare sau aceasta pentru mai multe informații.

Valorile implicite sunt gestionate prin atribuirea lui K unui tip implicit de "bare"; astfel, dacă compilatorul nu poate deduce K din apelul tău la fn(), acesta va reveni la "bare". În mod similar, facem ca variabila returnType să aibă ca valoare implicită "bare" în interiorul implementării prin atribuirea implicită din destructurarea JavaScript și prin parametrul implicit al funcției...

...dar compilatorul nu poate vedea că aceste valori implicite vor apărea împreună. De aceea folosim aserțiuni de tip. Prin scrierea "bare" as K, spun că, în cazurile în care folosim valoarea implicită pentru returnType, vom folosi de asemenea valoarea implicită pentru K. Este tehnic posibil ca cineva să scrie fn<"none">(1), și în acest caz lucrurile s-ar strica. Dar probabil nimeni nu va face asta, așa că putem presupune doar că K va fi "bare" când se folosesc valorile implicite.


Și iată. Are rezultatele dorite:

const x1: number = fn(1, { returnType: "bare" });
const x2: NumberWrapper = fn(1, { returnType: "wrapped" });
const x3: void = fn(1, { returnType: "none" });
const x4: number = fn(1, {});
const x5: number = fn(1);

Link de teren de joacă către cod