Lazy is Easy

Evaluating lazy helps to save computation power. But, how we can implement one?

TL;DR

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:

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:

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:

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 :smile:

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 :sweat_smile:.

TL;DR

Back to Top