Note: This post is also available on my personal blog. If you prefer to read it there, please click here
In this post I will be going through how to write a type safe function which takes a path and an object and returns the value at that path. This is similar to lodash's "get" function except that we will make it obey much stricter rules!
The requirements for the function:
- throws a compiler error when accessing an object property that doesn't exist
- correctly infers the return type
- Is a curried function with the arguments flipped (because I'm a functional kinda guy)
Now, you may ask yourself why would I not just use one of the many already existing "get" or "prop" functions provided by libraries like Lodash and Ramda?
The reason is that depending on how strict you want to be with your types, the implementation provided by those libraries may not be sufficient for your needs. Let's have a closer look at the behaviour of Lodash's get function to see why limitations it doesn't satisfy our requirement.
Firstly, lodash.get
does a decent job of inferring the return type when the path we look for exists in the object.
import _ from "lodash";
type MyObject = {
what: boolean;
am: string;
i: number;
doing: { value: string };
};
const myObject: MyObject = {
what: true,
am: 'hi',
i: 0,
doing: { value: 'x' }
};
const what = _.get(myObject, "what"); // what: boolean
const am = _.get(myObject, "am"); // am: string
const i = _.get(myObject, "i"); // i: number
const doing = _.get(myObject, "doing"); // doing: { value: string }
However, Lodash as a library generally allows you to pass any types to it's functions without throwing errors most of the time. In a JS application, this can be handy as you can be sure to avoid TypeError's, but as we are using Typescript we should be able to use the compiler to achieve the same thing instead. Next, let's look at what happens if we try to access a path which doesn't exist in the
object or we pass in values which are not objects:
import _ from "lodash"
type AnotherObject = {
something: boolean
}
const anotherObject: AnotherObject = {
something: true
}
// try to get "value" which doesn't exist in anotherObject
const value = _.get(anotherObject, "value") // value: any
// try passing in undefined as the object
const x = _.get(undefined, "value") // x: undefined
// try passing in a number as the object
const y = _.get(0, "value") // y: any
As we can see, the type inference in these cases is not good. In the first case Lodash doesn't even manage to infer that the value variable will be undefined. In the final case where we pass in a number, the result is inferred as "any" although it should be undefined here as well. Furthermore, in our stricter prop
function we would like to see a compiler error for all of these last three cases.
Now that we've seen what the limitations of Lodash's get function are, let's try writing our own prop function to satisfy our needs.
From our original requirements we know we need a curried function which first takes a path which is a string, then an object and returns the value at that path in the object.
Naive attempt:
// This naive first attempt isn't descriptive enough of the object argument
// type or our return type. This results in poor type inference when
// using the function.
const prop = (path: string) => (obj: Record<string, any>) => obj[path]
const x = prop('value')({ value: true }) // x: any
The compiler needs more information about the return type in order to infer it correctly. Let's try and improve what we have using generics.
// We are now telling the compiler that the return value is O[P] where O is our
// object and P is our path.
const prop = <P extends string>(path: P) =>
<O extends Record<string, any>>(obj: O): O[P] =>
obj[path]
const x = prop('value')({ value: true }) // x: boolean
const y = prop('value')({ anotherProperty: 'string', property: 0 }) // y: number
Great! Our return type is being inferred correctly now. However, what happens if we try to access a path which doesn't exist in the object?
const x = prop('non-existant-property')({ value: true }) // x: unknown
The compiler tells us that the return type is unknown, which is not what we want. We know what keys properties our object has so we should be able to make the compiler understand that the key non-existant-property
does not exist in an object like { value: true }
. The problem is that the type of the object argument in our prop
function is not descriptive enough.
In order to improve this we can use the in
operator which lets us map a set of types to a new set of types (for more information on type mapping: https://www.typescriptlang.org/docs/handbook/advanced-types.html#mapped-types).
We can use this to take the value of our path
argument and specify that this value must exist in our object.
// { [p in P]: V } specifies an object which must have a key of value p and that this
// key must pair with a value of V.
// Then, we say that our function returns this value V.
const prop = <P extends string>(path: P) => <V>(obj: { [p in P]: V }): V => obj[path]
const x = prop('value')({ value: true }) // x: boolean
// Now we correctly get the following compiler error for the below statement:
// 'value' does not exist in type '{ "non-existant-property": {}; }'.
const y = prop('non-existant-property')({ value: true })
// However, we have introduced a new problem.
// The following throws a compiler error:
// 'something' does not exist in type '{ value: boolean; }'.
const z = prop('value')({ something: 0, value: true })
We succeeded in adding the restriction that our object must have a key which is
equal to the path
argument. However, our type is now too restrictive and doesn't
allow any additional properties on the object. We can fix this by telling the compiler that the object can also have any other keys and values.
// we add "{ [key: string]: any }" to our object's type to specify that it can
// include any other properties
const prop = <P extends string>(path: P) =>
<V>(obj: { [p in P]: V } & { [key: string]: any }): V =>
obj[path];
// Hooray! This now compiles correctly
const z = prop("value")({ something: 0, value: true }); // z: boolean
Let's review our function requirements to see whether we have satisfied them:
-
throws a compiler error when accessing an object property that doesn't exist
Yes, if we pass a
path
argument which doesn't exist in our object we get a compiler error. -
correctly infers the return type
Yes, our return type is inferred correctly.
-
Is a curried function with the arguments flipped
Yes, our first argument is the
path
and the function is curried so the arguments can be partially applied, allowing for better functional composition.
Great, our function satisfies all our requirements and we now have a prop
function which is type-safe and can be used throughout our application nicely!
Top comments (3)
I feel like there's a better solution. This one will hint at which properties to select from.
Thanks for the suggestion!
That would certainly work and has the benefit of autocompletion for the key but it unfortunately requires you to pass the generic types to specify what
T
andK
should be (e.g.prop<User, "email">("email")
). Without that, the code doesn't compile at all:It's actually a strangely difficult function to implement in Typescript without any tradeoffs. I have a PR open for the fp-ts library to add a
prop
function and we iterated over several implementations - you can see the discussion here if you're interested :)I don't really use it unless it's inline like a function, so it satisfies my needs. PR looks good, nice type Def. Having a standalone prop function to apply later looks good!