Can we securely retrieve nested properties from an object using an array of keys in TypeScript? Is there a method to accomplish this in a manner that is type-safe and easily combinable?

I wish to create a function that retrieves a value from an object using an array of property keys. Here's an example implementation:

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
}

This function should operate as follows:

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

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

// The following calls should type check and return the expected values:
getValue(o, ['a']) // returns 1
getValue(o, ['b']) // returns "hi"
getValue(o, ['c','lark']) // returns true

// These calls should not type check:
getValue(o, ['a','b'])
getValue(o, ['d'])

It is important to have a type available (like ObjKeys<O>) so that this function can be easily utilized in other functions while maintaining typing integrity. For instance, one might want to do something like:

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 to our getValue function above, and ideally, it would all pass type checking because the object O and keys ObjKeys<O> are valid arguments for the getValue function being called.

This concept extends to returning the value obtained by getValue; one might also want to 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 determine the return type, ensuring full typechecking.

Is there a solution to this challenge, or is it currently impossible in TypeScript (as of version 4)?

The best approach I've found so far:

I can define a function that facilitates nested access with code similar to 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

With this method, proper type checking occurs when accessing values up to three layers deep.

However, I encounter difficulties when trying to use that function while preserving type safety in another context:

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 unsure what to substitute for ???? to inform TypeScript about the relationships between types for successful type checking. Is there a way to avoid having to redefine the list of overloads every time functions like these are written, yet still achieve the desired type checking?

Answer №1

Pushing the boundaries of what I can extract from the type system is getting quite close. TypeScript 4.1 is set to introduce support for recursive conditional types, but I anticipate that even with this addition, issues like circularity errors, "type instantiation too deep" errors, or other strange errors may still arise when using getValue() generically. Therefore, proceeding with caution would be wise for the following implementation:


In a different context highlighted in another discussion, I discussed a method to prompt the compiler to provide a union of all valid key paths for an object, represented as a tuple structure. Here's how it looks:

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]
    : [];

Verification:

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

The subsequent definition of DeepIdx<T, KS> determines the type of the property at the key path KS (where KS extends Paths<T> should hold), specifically tailored 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

Verification:

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

Utilizing these constructs, structuring your getValue() without overloads becomes feasible:

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;
}

Functionality verification:

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

Moreover, incorporating this definition into areValuesEqual() by defining keys with the type Paths<O> proves beneficial:

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(), introducing a generic structure for keys is necessary to inform the compiler about the expected output:

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
}

While a detailed explanation of each type could be provided, it involves complex constructs designed to guide type inference appropriately (e.g., | [] indicates a tuple context) and avoid immediate circularity warnings (the existence of the Prev tuple imposes constraints on the recursion depth). Given its complexity, such detailed explanations might not prove useful for real production code.

As an alternative, enforcing more constraints at runtime and opting for methods like PropertyKey[] for keys might provide a simpler approach. Within the implementation of areValuesEqual(), utilizing any[] or type assertions serves as another pragmatic workaround to address compiler concerns.


Explore the code further on the playground

Similar questions

If you have not found the answer to your question or you are interested in this topic, then look at other similar questions below or use the search

How can I achieve the same functionality as C# LINQ's GroupBy in Typescript?

Currently, I am working with Angular using Typescript. My situation involves having an array of objects with multiple properties which have been grouped in the server-side code and a duplicate property has been set. The challenge arises when the user updat ...

What is the injection token used for a specialized constructor of a generic component?

I created a versatile material autocomplete feature that I plan to utilize for various API data such as countries, people, and positions. All of these datasets have common attributes: id, name. To address this, I defined an interface: export interface Auto ...

Having trouble implementing catchError in a unit test for an HttpInterceptor in Angular 11

I am facing challenges in completing a unit test for my HttpInterceptor. The interceptor serves as a global error handler and is set to trigger on catchError(httpResponseError). While the interceptor functions perfectly fine on my website, I am struggling ...

Problems with installing ambient typings

I'm having trouble installing some ambient typings on my machine. After upgrading node, it seems like the typings are no longer being found in the dt location. Here is the error message I am encountering: ~/w/r/c/src (master ⚡☡) typings search mo ...

The process of running npx create-react-app with a specific name suddenly halts at a particular stage

Throughout my experience, I have never encountered this particular issue with the reliable old create-react-app However, on this occasion, I decided to use npx create-react-app to initiate a new react app. Below is a screenshot depicting the progress o ...

Rendering illuminated component with continuous asynchronous updates

My task involves displaying a list of items using lit components. Each item in the list consists of a known name and an asynchronously fetched value. Situation Overview: A generic component named simple-list is required to render any pairs of name and va ...

Steps for incorporating a toggle feature for displaying all or hiding all products on the list

Looking for some guidance: I have a task where I need to display a limited number of products from an array on the page initially. The remaining items should only be visible when the user clicks the "Show All" button. Upon clicking, all items should be rev ...

What is causing certain code to be unable to iterate over values in a map in TypeScript?

Exploring various TypeScript idioms showcased in the responses to this Stack Overflow post (Iterating over Typescript Map) on Codepen. Below is my code snippet. class KeyType { style: number; constructor(style) { this.style = style; }; } fu ...

What is the best way to eliminate the content of an element using javascript/typescript?

The progress bar I'm working with looks like this: <progress class="progress is-small" value="20" max="100">20%</progress> My goal is to use javascript to remove value="20", resulting in: <progre ...

tsc does not support the use of the '--init' command option

Encountering an error when running npx tsc --init: $ npx tsc --init npx: installed 1 in 1.467s error TS5023: Unknown compiler option 'init'. I've added the typescript package using Yarn 2: $ yarn add -D typescript ➤ YN0000: ┌ Resolution ...

Having trouble getting web components registered when testing Lit Element (lit-element) with @web/test-runner and @open-wc/testing-helpers?

Currently, I am working with Lit Element and Typescript for my project. Here are the dependencies for my tests: "@esm-bundle/chai": "^4.3.4-fix.0", "@open-wc/chai-dom-equals": "^0.12.36", "@open-wc/testing-help ...

Trouble arises when extending an MUI component due to a TypeScript error indicating a missing 'css' property

We have enhanced the SnackbarContent component by creating our own custom one called MySnackbarContent: export interface MySnackbarContentProps extends Omit<SnackbarContentProps, 'variant'> { variant?: MyCustomVariant; type?: MyCustomTy ...

Troubleshooting Angular: Unidentified property 'clear' error in testing

I've implemented a component as shown below: <select #tabSelect (change)="tabLoad($event.target.value)" class="mr-2"> <option value="tab1">First tab</option> <op ...

Unexpected behavior with HashLocationStrategy

I am currently tackling a project in Angular2 using TypeScript, and I seem to be having trouble with the HashLocationStrategy. Despite following the instructions on how to override the LocationStrategy as laid out here, I can't seem to get it to work ...

Error: Typescript foreach loop encountering 'Expression yields void type'

Currently, I am working on setting up a cron job to monitor the completion of my tournaments and trigger some specific code upon completion. For reference, I came across this example: During deployment of my code, an error popped up as follows: ERROR: fu ...

Hermes, the js engine, encountered an issue where it was unable to access the property 'navigate' as it was undefined, resulting

Whenever I switch from the initial screen to the language selection screen, I encounter this error and have exhausted all possible solutions. I attempted to utilize "useNavigation" but still encountered errors, so I resorted to using this method instead. T ...

The typescript compiler encounters an error when processing code that includes a generator function

Recently, I started learning about TypeScript and came across the concept of generators. In order to experiment with them, I decided to test out the following code snippet: function* infiniteSequence() { var i = 0; while(true) { yield i++ ...

Angular 14: Enhance Your User Experience with Dynamic Angular Material Table Row Management

My inquiry: I have encountered an issue with the Angular material table. After installing and setting up my first table, I created a function to delete the last row. However, the table is not refreshing as expected. It only updates when I make a site chang ...

Experiencing the 'invalid_form_data' error while attempting to upload a file to the Slack API via the files.upload method in Angular 8

I am currently working on a project that involves collecting form data, including a file upload. I am trying to implement a feature where the uploaded file is automatically sent to a Slack channel upon submission of the form. Despite following the guidance ...

Unexpectedly, optimization causing issues on Angular site without explanation

Currently, I am utilizing Angular to construct a front-end website that searches for and showcases information obtained through API requests. Whenever I compile the project into a file bundle for static deployment using ng build, I notice that the resultin ...