2

I'd like to write a function which gets a value from an object given an array of property keys. It would look something like this:

function getValue<O, K extends ObjKeys<O>>(obj: O, keys: K): ObjVal<O,K> {
  let out = obj;
  for (const k in keys) out = out[k]
  return out
}

I'd like this function to work like so:

type Foo = {
  a: number
  b: string
  c: {
    lark: boolean
    wibble: string
  }
}

let o: Foo = {
  a: 1,
  b: "hi",
  c: {
    lark: true,
    wibble: "there"
  }
}

// I'd like these to type check (and return the expected values):
getValue(o, ['a']) // returns 1
getValue(o, ['b']) // returns "hi"
getValue(o, ['c','lark']) // returns true

// I'd like these _not_ to type check:
getValue(o, ['a','b'])
getValue(o, ['d'])

Importantly, I'd like to have a type (like ObjKeys<O> in the above example) available so that I can make use of this function easily from other functions, while preserving the typing. For instance I might want to do something like this:

function areValuesEqual<O>(obj: O, oldObj: O, keys: ObjKeys<O>) {
  let value = getValue(obj, keys)
  let oldValue = getValue(oldObj, keys)
 
  return value === oldValue ? true : false 
}

This function takes some keys and passes them through to our getValue above, and ideally it would all type check because the object O and keys ObjKeys<O> are both valid arguments to the getValue function called within.

This extends to returning the value given back by getValue; I might want to also do something like this:

function doSomethingAndThenGetValue<O>(obj: O, oldObj: O, keys: ObjKeys<O>): ObjVal<O> {
  let value = getValue(obj, keys)
  console.log("Value obtained is:", value)
  return value
}

This also uses something like ObjVal<O> to know what the return type will be, and so would fully typecheck.

Is there a solution to this, or is there simply no way to do this sort of thing in TypeScript as it stands (version 4 at time of writing)?

The best I have so far:

I can define a function that allows for nested access with something like the following:

function getValue<
    O  extends object, 
    K1 extends keyof O
>(obj: O, keys: [K1]): O[K1]
function getValue<
    O  extends object, 
    K1 extends keyof O, 
    K2 extends keyof O[K1]
>(obj: O, keys: [K1,K2]): O[K1][K2]
function getValue<
    O  extends object, 
    K1 extends keyof O, 
    K2 extends keyof O[K1], 
    K3 extends keyof O[K1][K2]
>(obj: O, keys: [K1,K2,K3]): O[K1][K2][K3]
function getValue<O>(obj: O, keys: Key | (Key[])): unknown {
  let out = obj;
  for (const k in keys) out = out[k]
  return out
}
type Key = string | number | symbol

And then this typechecks properly as I try to access values (up to 3 layers deep in this case).

However, I get a bit stuck when I want to use that function from another while preserving type safety:

function areValuesEqual<O>(obj: O, oldObj: O, keys: ????) {
  let value = getValue(obj, keys)
  let oldValue = getValue(oldObj, keys)
 
  return value === oldValue ? true : false 
}

function doSomethingAndThenGetValue<O>(obj: O, oldObj: O, keys: ????): ???? {
  let value = getValue(obj, keys)
  console.log("Value obtained is:", value)
  return value
}

I'm not sure what I could put in place of ???? to tell TypeScript how the types relate to each other so that this would type check. Can I avoid having to rewrite my big list of overloads each time I want to write functions like the above, but still ge tthe type checking I want?

jsdw
  • 5,424
  • 4
  • 25
  • 29

1 Answers1

3

This is getting close to the limits of what I can get out of the type system. TypeScript 4.1 will support recursive conditional types, but even with them I imagine you'll quite possibly get circularity errors, "type instantiation too deep" errors, or other weird errors on anything that tries to use getValue() generically. So I'm not sure I'd actually recommend you use what I'm going to write below:


In another question I wrote how to convince the compiler to give you a union of all the valid key paths of an object, represented as a tuple. It looks like this:

type Cons<H, T> = T extends readonly any[] ? [H, ...T] : never;
type Prev = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10,
    11, 12, 13, 14, 15, 16, 17, 18, 19, 20, ...0[]]
type Paths<T, D extends number = 10> = [D] extends [never] ? never : T extends object ?
    { [K in keyof T]-?: [K] | (Paths<T[K], Prev[D]> extends infer P ?
        P extends [] ? never : Cons<K, P> : never
    ) }[keyof T]
    : [];

You can verify that

type FooPaths = Paths<Foo>;
// type FooPaths = ["a"] | ["b"] | ["c"] | ["c", "lark"] | ["c", "wibble"]

The following definition of DeepIdx<T, KS> will give the type of the property at the key path KS (where KS extends Paths<T> should be true), but in only works like this for TS4.1+:

type DeepIdx<T, KS extends readonly any[]> = KS extends readonly [infer K, ...infer KK] ?
    K extends keyof T ? DeepIdx<T[K], KK> : never : T

You can verify that

type FooCWibble = DeepIdx<Foo, ["c", "wibble"]>;
// type FooCWibble = string

Armed with those, your getValue() can be typed like this without overloads:

function getValue<O, KK extends Paths<O> | []>(
    obj: O, keys: KK
): DeepIdx<O, KK> {
    let out: any = obj;
    for (const k in keys) out = out[k as any]
    return out;
}

You can verify that these work:

const num = getValue(o, ['a']) // number
const str = getValue(o, ['b']) // string 
const boo = getValue(o, ['c', 'lark']) // boolean

getValue(o, ['a', 'b']) // error!
// -------------> ~~~
// b is not assignable to lark | wibble
getValue(o, ['d']) // error!
// --------> ~~~
// d is not assignable to a | b | c

And then this definition also works inside areValuesEqual() if you give keys the type Paths<O>:

function areValuesEqual<O>(obj: O, oldObj: O, keys: Paths<O>) {
    let value = getValue(obj, keys)
    let oldValue = getValue(oldObj, keys)
    return value === oldValue ? true : false
}

For doSomethingAndThenGetValue() you have to make keys generic so the compiler knows what is coming out:

function doSomethingAndThenGetValue<O, K extends Paths<O>>(
  obj: O, 
  oldObj: O, 
  keys: K
): DeepIdx<O, K> {
    let value = getValue(obj, keys)
    console.log("Value obtained is:", value)
    return value
}

I could explain exactly how all of those types work, but it's a bit involved and there's some constructs specifically tailored to coax type inference to work the right way (the | [] hints at a tuple context) or to avoid an immediate circularity warning (the Prev tuple is there to place a governor on the maximum depth of recursion), and I don't know how useful it is to explain in detail something I'd seriously hesitate from putting in any production code base.

For your purposes you might want to just enforce the constraints more at runtime and do something like PropertyKey[] for keys. Inside the implementation of areValuesEqual() or you can just use any[] or a type assertion to make the compiler accept it.


Playground link to code

halfer
  • 19,824
  • 17
  • 99
  • 186
jcalz
  • 264,269
  • 27
  • 359
  • 360
  • Thank you, that was a fascinating answer! You're quite right as well; this is too complex for me to want to introduce in shared code, so I'll think up some alternate approaches to what I was working on. – jsdw Aug 31 '20 at 12:06