typescript - Hämta värde på djupt nästlad egenskap med hjälp av array


Jag skulle vilja deklarera en funktion som kan ta ett objekt samt en array av nästlade egenskapsnycklar och härleda typen av det nästlade värdet som returtypen för funktionen.

t.ex.

const value = byPath({ state: State, path: ['one', 'two', 'three'] }); 
// return type == State['one']['two']['three']

const value2 = byPath({ state: State, path: ['one', 'two'] });
// return type == State['one']['two']

Det bästa jag har kunnat få ihop är följande, men det är mer ordrikt än jag skulle vilja, och jag måste lägga till en funktionsöverlagring för varje nivå av nästling.

export function byPath<
  K1 extends string,
  R
>({ state, path }: {
  state: {[P1 in K1]?: R},
  path: [K1]
}): R;

export function byPath<
  K1 extends string,
  K2 extends string,
  R
>({ state, path }: {
  state: {[P1 in K1]?: {[P2 in K2]?: R}},
  path: [K1, K2]
}): R;

export function byPath<
  K1 extends string,
  K2 extends string,
  K3 extends string,
  R
>({ state, path }: {
  state: {[P1 in K1]?: {[P2 in K2]?: {[P3 in K3]?: R}}},
  path: [K1, K2, K3]
}): R;

export function byPath<R>({ state, path }: { state: State, path: string[] }): R | undefined {
  // do the actual nested property retrieval
}

Finns det ett enklare sätt att göra detta med färre kodrader och utan att lägga till en funktionsöverlagring för varje nivå av nästling?

Svar #1

NYTT SVAR FÖR TypeScript 5.0+

Nu när TypeScript stöder rekursiva villkorstyper samt, tja, en hel del nya funktioner, skulle jag förmodligen skriva koden på det här sättet:

type DeepIdx<T, K extends readonly PropertyKey[]> =
    K extends readonly [
      infer K0 extends keyof T, 
      ...infer KR extends readonly PropertyKey[]
    ] ? DeepIdx<T[K0], KR> : T;

type DeepRecord<K extends readonly PropertyKey[], V> =
    K extends readonly [
      infer K0 extends PropertyKey, 
      ...infer KR extends readonly PropertyKey[]
   ] ? { [P in K0]: DeepRecord<KR, V> } : V;

declare function byPath<
  T extends DeepRecord<K, any>, 
  const K extends readonly PropertyKey[]
>({ state, path }: { state: T, path: K }): DeepIdx<T, K>;

vilket producerar detta beteende:

const State = { one: { two: { three: { four: 123 } } } };

const value = byPath({ state: State, path: ['one', 'two', 'three'] });
// const value: {four: number};
const oops = byPath({ state: State, path: ['one', 'too', 'three'] }); // error!
// -----------------> ~~~~~
// Property 'too' is missing in type '{ two: { three: { four: number; }; }; }

Playground-länk till kod


ORIGINALSVAR för TypeScript 3.2:

Tyvärr tillåter TypeScript för närvarande inte godtyckliga rekursiva typfunktioner, vilket är vad du vill för att iterera genom en lista med nycklar, borra ned i en objekttyp och komma ut med typen av den nästlade egenskapen som motsvarar listan med nycklar. Du kan göra delar av det, men det är ett elände.

Så du kommer att behöva välja en maximal nivå av nästling och skriva för den. Här är en möjlig typdeklaration för din funktion som inte använder överlagringar:

type IfKey<T, K> = [K] extends [keyof T] ? T[K] : T;

declare function byPath<T0,
  K1 extends keyof T0 | undefined, T1 extends IfKey<T0, K1>,
  K2 extends keyof T1 | undefined, T2 extends IfKey<T1, K2>,
  K3 extends keyof T2 | undefined, T3 extends IfKey<T2, K3>,
  K4 extends keyof T3 | undefined, T4 extends IfKey<T3, K4>,
  K5 extends keyof T4 | undefined, T5 extends IfKey<T4, K5>,
  K6 extends keyof T5 | undefined, T6 extends IfKey<T5, K6>
>({ state, path }: { state: T0, path: [K1?, K2?, K3?, K4?, K5?, K6?] }): T6;

Observera att du lätt kan utöka det till mer än sex lager av nästling om du behöver.

Så här fungerar det: det finns två typer av typparametrar... nyckeltyper (namngivna K1, K2, osv), och objekttyper (namngivna T0, T1, osv). state-egenskapen är av typen T0, och vägen är en tuppel med valfria element av nyckeltyperna. Varje nyckeltyp är antingen en nyckel till den tidigare objekttypen, eller undefined. Om nyckeln är undefined, blir nästa objekttyp samma som den nuvarande objekttypen; annars är det typen av den relevanta egenskapen. Så snart nyckeltyperna blir och förblir undefined, blir och förblir objekttyperna den sista relevanta egenskapstypen... och den sista objekttypen (T6 ovan) är returtypen för funktionen.

Låt oss ta ett exempel: om T0 är {a: {b: string}, c: {d: string}}, då måste K1 vara en av 'a', 'd' eller undefined. Låt oss säga att K1 är 'a'. Då är T1 {b: string}. Nu måste K2 vara 'b' eller undefined. Låt oss säga att K2 är 'b'. Då är T2 string. Nu måste K3 vara i keyof string eller undefined. (Så K3 kan vara "charAt", eller någon av string-metoderna och egenskaperna). Låt oss säga att K3 är undefined. Då är T3 string (eftersom det är samma som T2). Och om alla de övriga K4, K5 och K6 är undefined, då är T4, T5 och T6 bara string. Och funktionen returnerar T6.

Så om du gör detta samtal:

const ret = byPath({state: {a: {b: "hey"}, c: {d: "you"} }, path: ['a', 'b'] });

Då kommer T0 att härledas som {a: {b: string}, c: {d: string}, K1 kommer att vara 'a', K2 kommer att vara 'b', och K3 till K6 kommer alla att vara undefined. Vilket är exemplet ovan, så T6 kommer att vara string. Och således kommer ret att vara av typen string.

Ovanstående funktionssignatur bör också skrika åt dig om du anger en felaktig nyckel:

const whoops = byPath({ state: { a: { b: "hey" }, c: { d: "you" } }, path: ['a', 'B'] });
// error! type "B" is not assignable to "b" | undefined: ----------------------> ~~~

Det felet är logiskt, eftersom B inte är giltigt. Följande skriker också åt dig:

const alsoWhoops = byPath({ state: { a: { b: "hey" }, c: { d: "you" } }, path: ['A', 'b'] });
// error! type "A" is not assignable to "a" | "c" | undefined: ---------------> ~~~
// also error! Type "b" is not assignable to "a" | "c" | undefined ?! -------------> ~~~

Det första felet är precis vad du skulle förvänta dig; det andra är lite konstigt, eftersom "b" är okej. Men kompilatorn har nu ingen aning om vad den ska förvänta sig för keyof T['A'], så den agerar som om K1 vore undefined. Om du åtgärdar det första felet försvinner det andra. Det kan finnas sätt att ändra byPath()-signaturen för att undvika detta, men det verkar mindre viktigt för mig.


Om du bryr dig om det felaktiga andra felmeddelandet kan du använda det något mer komplexa:

type IfKey<T, K> = [K] extends [keyof T] ? T[K] : T
type NextKey<T, K = keyof any> = [K] extends [undefined] ? undefined :
  [keyof T | undefined] extends [K] ? keyof any : (keyof T | undefined)

declare function byPath<T0,
  K1 extends NextKey<T0>, T1 extends IfKey<T0, K1>,
  K2 extends NextKey<T1, K1>, T2 extends IfKey<T1, K2>,
  K3 extends NextKey<T2, K2>, T3 extends IfKey<T2, K3>,
  K4 extends NextKey<T3, K3>, T4 extends IfKey<T3, K4>,
  K5 extends NextKey<T4, K4>, T5 extends IfKey<T4, K5>,
  K6 extends NextKey<T5, K5>, T6 extends IfKey<T5, K6>
>({ state, path }: { state: T0, path: [K1?, K2?, K3?, K4?, K5?, K6?] }): T6;

vilket är i princip detsamma förutom när saker går fel med nycklar som inte matchar det de ska matcha.

Relaterade artiklar