Hur man korrekt bestämmer returtypen för en funktion beroende på värdet i argumentet (TypeScript)?


Det finns ett fall när returtypen redan är känd för mig när jag skickar en parameter till en funktion, men jag har svårt att försöka förklara detta för TypeScript.

export enum DatePreset {
  Today = "Today",
  ThisMonth = "This month",
  ThisYear = "This year",
  Custom = "Custom"
}

export interface DateRange {
  from: Date;
  to?: Date;
}

export interface DateRangeWithPreset extends DateRange {
  preset: DatePreset;
}

export function applyPreset<T extends DatePreset = DatePreset>(
  preset: T
): T extends DatePreset.Custom ? undefined : Required<DateRangeWithPreset> {
  const presets: Record<
    Exclude<DatePreset, DatePreset.Custom>,
    Required<DateRange>
  > = {
    [DatePreset.Today]: { from: new Date(), to: new Date() },
    [DatePreset.ThisMonth]: { from: new Date(), to: new Date() },
    [DatePreset.ThisYear]: { from: new Date(), to: new Date() }
  };

  // Delete these comments and try to help me solve this problems with types.

  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
  // @ts-ignore
  // readyPreset: any
  const readyPreset = presets[preset];

  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
  // @ts-ignore
  return readyPreset && { preset, ...readyPreset };
}

// Right behaviors:

// const validUndefinedRange: undefined
const validUndefinedRange = applyPreset(DatePreset.Custom);

// const thisYearRange: Required<DateRangeWithPreset>
const thisYearRange = applyPreset(DatePreset.ThisYear);

console.log("validUndefinedRange", validUndefinedRange);
console.log("thisYearRange", thisYearRange);

Det finns en funktion som returnerar ett objekt med värden från, till och förinställning. Vilket intervall som returneras beror på parametern. I mitt fall kommer DatePreset.Custom-parametern alltid att returnera undefined, och resten - ett objekt. Jag hittade en fungerande lösning, men Eslint och TS gillar den inte. Hjälp mig att skriva typningen av funktionen korrekt.

Här är CodeSandbox.

Svar #1

Om du vill behålla din funktion som den är, som returnerar en villkorlig typ som beror på en generisk typparameter, så är det verkligen inte möjligt att få kompilatorn att verifiera typesäkerhet. Se microsoft/TypeScript#33912 för feature-förfrågan om att möjliggöra ett sätt för kompilatorn att verifiera säkerheten för sådana funktioner. För tillfället finns det inget stöd för detta. Så du skulle behöva ta på dig bördan av type-säkerhetsverifiering själv och helt enkelt tala om för kompilatorn att inte oroa sig för det.

Jag skulle aldrig rekommendera att du använder // @ts-ignore-direktivet förutom som en sista utväg. Det är i princip bara att undertrycka och inte åtgärda felet. Istället kan du använda typframhävningar så här:

export function applyPresetAssert<T extends DatePreset = DatePreset>(
  preset: T
) {
  const presets: Record<
    Exclude<DatePreset, DatePreset.Custom>,
    Required<DateRange>
  > & { [DatePreset.Custom]?: undefined } = { 
  //^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  // tell the compiler about this property being undefined
    [DatePreset.Today]: { from: new Date(), to: new Date() },
    [DatePreset.ThisMonth]: { from: new Date(), to: new Date() },
    [DatePreset.ThisYear]: { from: new Date(), to: new Date() }
  };

  const readyPreset = presets[preset]; // okay

  return (readyPreset && { preset, ...readyPreset }) as 
  (undefined | Required<DateRangeWithPreset>) as 
  (T extends DatePreset.Custom ? undefined : Required<DateRangeWithPreset>);
}

Den sekvensen av as-uttryck i slutet är det påstående du behöver göra för att undertrycka felet på ett kompilatorvänligt sätt. Du berättar för kompilatorn att det resulterande värdet kan betraktas som den unionstypen, som sedan kan betraktas som den villkorliga typen.

Om du gör ett misstag i din implementation kommer kompilatorn förmodligen inte att fånga det, så du måste vara försiktig.


Men du sa att du bryr dig mer om kompilatorverifierad typäkerhet än att behålla din funktion som den är. Så du kan refaktorera för att få ett beteende som kompilatorn förstår. Generiska villkorstyper måste överges. Istället kan du få liknande beteende genom att indexera ett objekt med en generisk nyckel; om du har ett värde obj av typen Obj, och en nyckel k av den generiska typen K, så kommer kompilatorn förstå att obj[k] har den indexerade åtkomsttypen Obj[K].

Så låt oss omstrukturera så att du utför en enda objektuppslagning:

export function applyPreset<T extends DatePreset = DatePreset>(
  preset: T
) {
  const wrap = <T extends object>(x: T) => ({ preset, ...x });
  const presets:
    { [K in DatePreset]:
      K extends DatePreset.Custom ? undefined : Required<DateRangeWithPreset>
    } = {
    get [DatePreset.Today]() { return wrap({ from: new Date(), to: new Date() }) },
    get [DatePreset.ThisMonth]() { return wrap({ from: new Date(), to: new Date() }) },
    get [DatePreset.ThisYear]() { return wrap({ from: new Date(), to: new Date() }) },
    [DatePreset.Custom]: undefined
  };
  return presets[preset];
}

Så presets-objektet är inte längre en statisk sak, utan skapas på flykten som en funktion av preset-parametern. Jag använde getters så att endast den egenskap som faktiskt nås kommer att köra koden, men du behöver inte göra detta. Kompilatorn kan verifiera att typen av presets matchar dess annoterade typ: för alla DatePreset-nycklar utom Custom, är egenskapsvärdet av typen Required<DateRangeWithPreset>, och annars är det undefined.

Och sedan returnerar vi bara presets[preset], vilket kompilatorn ser som av typen

{
    Today: Required<DateRangeWithPreset>;
    "This month": Required<DateRangeWithPreset>;
    "This year": Required<DateRangeWithPreset>;
    Custom: undefined;
}[T]

Låt oss prova det:

const validUndefinedRange = applyPreset(DatePreset.Custom);
// const validUndefinedRange: undefined
console.log("validUndefinedRange", validUndefinedRange);
// "validUndefinedRange",  undefined 

const thisYearRange = applyPreset(DatePreset.ThisYear);
// const thisYearRange: Required<DateRangeWithPreset>
console.log("thisYearRange", thisYearRange);
/* "thisYearRange",  {
  "preset": "This year",
  "from": "2022-10-01T16:25:21.296Z",
  "to": "2022-10-01T16:25:21.296Z"
*/

Ser bra ut. När du anropar funktionen ger kompilatorn fortfarande rätt typer och implementeringen ger fortfarande rätt värden. Och du behöver inte undertrycka några kompilatorfel.

Playground-länk till kod