Recently, I came across a use case in TypeScript where the properties of an object were expensive to calculate, and they were not used that frequently. In summary, the function looked like this:
const doHeavyComputation = (args) => {
// do something with args
return value
}
const aFunctionReturningObject = () => {
// Do some preparation
return {
property1: doHeavyComputation(args1),
property2: doHeavyComputation(args2),
// other properties
}
}
And, its usage looked somewhat like this
const user = () => {
const returnedObject = aFunctionReturningObject()
if (returnedObject.property1 === someValue) {
// do Something with that value
return
} else if (
returnedObject.property2 === otherValue ||
returnedObject.property3
) {
// do some other computation
return
}
}
As you can see, calculating property2
and property3
is unnecessary if the
property1
equals someValue
. However, we know this only at the time of
usage, which does not help us in any way to optimize during the computation. In
this short article, let’s see how we can approach this problem with the
constraints of:
- Doing minimal modifications at the computation side
(
aFunctionReturningObject
) - Doing no modification at the site of usage
- Keeping the usages type-safe and syntactically clean (ideally, same as what we have written earlier)
Become Lazy
Turns out that this problem is pretty common in Computer Science and its
solution is called Lazy
Evaluation.
Lazy Evaluation is the method of evaluating an expression at the site of its
usage, rather than the declaration. In our function aFunctionReturningObject
,
we are calculating property1
, property2
, … during its declaration. With
lazy evaluation in place, they would be calculated when they are used (Here, in
user
, during returnedObject.property1
).
While Haskell uses Lazy Evaluation by default, and some languages like Kotlin
and Scala provide the lazy
keyword to achieve the same, we don’t have such an
option in JS. However, instead of returning a value, if we return a function
that returns the value, the value would not get calculated until the function
is called. In our case, it would look something like this:
const aFunctionReturningObject = () => {
// Do some preparation
return {
property1: () => doHeavyComputation(args1),
property2: () => doHeavyComputation(args2),
// other properties
}
}
const user = () => {
const returnedObject = aFunctionReturningObject()
if (returnedObject.property1() === someValue) {
// do Something with that value
return
} else if (
returnedObject.property2() === otherValue ||
returnedObject.property3()
) {
// do some other computation
return
}
}
However, this solution has a few problems:
- The property access is now a function call, which requires modifications at the user side
- Evaluating the same property twice, would require evaluating
doHeavyComputation()
twice, which might not be ideal if the heavy computation is returning the same values every time
Lazy Pro
Let’s address these problems by writing a function lazy
, that would take in
an object with values in the format () => any
, and make the property access a
regular one instead of a function call. It will also take care of caching the
evaluations.
To address the cases of intercepting the property access of an object and
redefining its behavior, JS provides a mechanism named
Proxy.
We can wrap our original object with the proxy, along with handlers to redefine
its behavior. Let’s look into the implementation of lazy
using Proxy
.
const lazy = <T extends {}>(object: T) => {
const cache = {} as T
return new Proxy(object, {
get(target, prop) {
const key = prop as keyof T
if (!(key in cache)) {
const value = target[key]
cache[key] = value ? value() : undefined
}
return cache[key]
},
})
}
The logic is straightforward albeit with an error:
- We take in a type
T
that extends{}
, as Proxy requires an object as its input - In
Proxy
, we intercept theget
method, which corresponds to all property accesses - The
get
method takestarget
(in our case, same asobject
) and theprop
, the property being accessed as parameters prop
can be anystring | symbol
, we cast it tokeyof T
askey
.- We check if
key
is thecache
, if it isn’t, we access it fromtarget
, check if it is not undefined, and evaluate it - Finally, we return
cache[key]
TS screams us with an error that we can not use value()
, as it is unknown
.
This is true, as we don’t know the values of the type {}
in advance. We need
to restrict ourselves to a subtype of {}
, that has () => any
as values.
type LazyInput = {
[Key: string]: () => any
}
Then, we can modify the signature of lazy
as,
const lazy = <T extends LazyInput>(object: T) => {
// same as before
}
Cool! the lazy
function is free of type errors now. Let’s check it in action.
Before that, to check if caching is working, let’s define a logAndReturn
function that does exactly what it is named 😄
const logAndReturn = <T>(value: T) => {
console.log("Logging", value)
return value
}
Then, let’s prepare input for lazy
const input = {
intValue: () => logAndReturn(2),
stringValue: () => logAndReturn("string")
}
Let’s call lazy
now!
const lazyOutput = lazy(input)
Let’s log its values
console.log(lazyOutput.intValue) // "Logging 2", 2
console.log(lazyOutput.intValue) // 2
console.log(lazyOutput.intValue) // 2
console.log(lazyOutput.stringValue) // "Logging string", string
console.log(lazyOutput.stringValue) // string
The log looks nice, on the first property access, it called logAndReturn
and
subsequent accesses used the value from the cache. However, most of the use
cases would not be to log the value, rather than to use it. Let’s try that as
well:
const addOne = (x: number) => x + 1
const addPrefix = (prefix: string, value: string) =>
`${prefix}${value}`
const intResult = addOne(lazyOutput.intValue)
const stringResult = addPrefix(
"result:",
lazyOutput.stringValue
)
console.log({ intResult, stringResult })
Ah, now we see an error in calls to addOne
!
Argument of type '() => number' is not assignable to parameter of type 'number'.
Argument of type '() => string' is not assignable to parameter of type 'string'.
It turns out that the type of lazyOutput
is still
type LazyOutput = typeof lazyOutput
/*
type LazyOutput = {
intValue: () => number;
stringValue: () => string;
}
*/
with the values being functions. But, we want their return types instead! We
can fix that by creating a type Lazy<T>
type Lazy<T extends LazyInput> = {
[Key in keyof T]: ReturnType<T[Key]>
}
Note that ReturnType
is a TS inbuilt type, that returns the return type of a
function. Now, we modify the lazy
function as:
const lazy = <T extends LazyInput>(object: T) => {
const cache = {} as T
return new Proxy(object, {
// same as before
}) as Lazy<T>
}
With this, the errors in the call of addOne
are now gone, and the logic in
the type domain matches that of the execution domain. The LazyOutput
type is
now,
type LazyOutput = typeof lazyOutput
/*
type LazyOutput = {
intValue: number;
stringValue: string;
}
*/
Which is what we expected. Now, we can return to our initial example and can rewrite it as:
const aFunctionReturningObject = () => {
// Do some preparation
return lazy({
property1: () => doHeavyComputation(args1),
property2: () => doHeavyComputation(args2),
// other properties
})
}
const user = () => {
const returnedObject = aFunctionReturningObject()
if (returnedObject.property1 === someValue) {
// do Something with that value
return
} else if (
returnedObject.property2 === otherValue ||
returnedObject.property3
) {
// do some other computation
return
}
}
In this code, we wrapped the return value with lazy
and deferred the
computations of the values by putting them inside a function. The lazy
function did not require us to change the user
function at all, which is very
good, as we don’t know from how many places aFunctionReturningObject
would be
called, especially if it is in a library.
Try it Yourself!
The code for this article is available on CodeSandbox. Feel free to fork it out and play with it.
Lazy Pro Max
Some of you might object to the key naming in the argument of lazy
. Since we
now have a function as a value, it would be rather good to name property1
as
getProperty1
, which would make things more clear. We can hand off the
responsibility of key renaming to Proxy
, which would convert the access of
property1
to getProperty1
call.
This is a valid objection. However, implementing these rules out that Lazy is Easy, thus, I will cover it in another article Lazy is Hard 😅.