TypeScript: Deep Property Access with Generics
Overview
When working with deeply nested objects in JavaScript or TypeScript, it's common to access properties several levels deep using multiple keys. However, this can lead to potential runtime errors, especially when the object structure changes or when trying to access non-existent properties. In TypeScript, we can leverage its powerful type system to ensure type-safe access to these nested values. In this article, we’ll explore how to write a utility function that retrieves values from deeply nested objects while maintaining type safety. We’ll begin with a simple implementation that lacks type safety and then enhance it using TypeScript generics and key constraints to prevent common errors and improve code maintainability. Along the way, we'll cover how this approach improves autocompletion and helps catch invalid property accesses at compile time, offering a more robust solution.
Initial (Less Type-Safe) Implementation
The first implementation does not take full advantage of TypeScript's type system. It uses any types, which sacrifices type safety and leads to a lack of autocompletion and potential runtime errors.
Example:
export const getDeepValue = (
obj: any,
firstKey: string,
secondKey: string
) => obj[firstKey][secondKey];
- The getDeepValue function accepts an object (obj) and two string keys (firstKey and secondKey).
- It retrieves the value from the obj by accessing obj[firstKey][secondKey].
However, this approach isn't type-safe, as demonstrated when calling the function:
const val = getDeepValue(obj, 'foo', 'a'); // type: 'any'
Even though the object structure is known, TypeScript cannot infer the type of val without explicit type definitions.
Preferred (Type-Safe) Implementation
The second version of getDeepValue is more advanced and fully leverages TypeScript's type system to ensure type safety.
export const getDeepValue = <
TObj,
TFirstKey extends keyof TObj,
TSecondKey extends keyof TObj[TFirstKey]
>(
obj: TObj,
firstKey: TFirstKey,
secondKey: TSecondKey
) => obj[firstKey][secondKey];
Usage:
1 - Generics: The function uses generics to make it more flexible and type-safe. It defines three type parameters:
- TObj: The type of the object.
- TFirstKey: A union type of keys within TObj, constrained by keyof TObj (all possible keys of the object).
- TSecondKey: A union type of keys inside the object returned byTObj[TFirstKey], constrained by keyof TObj[TFirstKey] (all possible keys of the nested object).
2 - Key Constraints:
- TFirstKey is constrained by keyof TObj, meaning it can only be one of the keys of obj (in the example, it could be 'foo' or 'bar').
- TSecondKey is constrained by keyof TObj[TFirstKey], meaning it can only be one of the keys of the nested object inside obj[firstKey] (e.g., for 'foo', this could be 'a' or 'b').
3 - Usage: The function is now aware of the nested structure and types, providing better autocompletion and preventing incorrect accesses
const obj = {
foo: {
a: true,
b: 99,
},
bar: {
c: '77',
d: 11,
},
};
Now, if you try to retrieve deeply nested values:
const val1 = getDeepValue(obj, 'foo', 'a');
// true, inferred as boolean
const val2 = getDeepValue(obj, 'bar', 'd');
// 11, inferred as number
The types of val1 and val2 are correctly inferred as boolean and number respectively. If you try to access an invalid key, TypeScript will throw an error:
const val3 = getDeepValue(obj, 'bar', 'b');
// Error: Type 'b' is not assignable to 'c' | 'd'
Key Takeaways
1 - Initial Implementation: The first implementation works but lacks type safety and autocompletion, which may lead to runtime errors and less efficient development.
2 - Preferred Implementation: By using TypeScript generics and keyof constraints, the second implementation ensures type safety when working with deeply nested objects, preventing incorrect key access and providing better development ergonomics.
Conclusion
The second approach using TypeScript's advanced type system ensures that developers working with deeply nested objects can enjoy both the flexibility of dynamic key access and the type safety that TypeScript provides. This makes the code more robust, maintainable, and easier to work with in the long term.