Symbol.lazy

The idea is to allow certain APIs to take arguments whose type is:

value | { [Symbol.lazy](): value }

This would allow APIs that take default values to ensure that the values are not constructed if they are not needed.

For example you could have:

Map.getOr(key, {[Symbol.lazy]() { return new ExpensiveClass  } })

The problem is interesting, but the proposed solution is not very intuitive. The following is already more readable:

map.getOr(key, LazyValue(() => new ExpensiveClass))

But the API can also be designed to take a callback that returns a value, instead of a value, when it is not sure that it will use that value:

map.getOr(key, new ExpensiveClass) // TypeError, 2nd arg must be a callback
map.getOr(key, () => new ExpensiveClass)  // ok
2 Likes

It also gets tricky if you want, e.g., the fallback parameter for map.getOr() to be a lazy value (however that gets defined). E.g.

SomeOtherFnThatAcceptsLazyValues(map.getOr(key, ))

Where is an object with a symbol or the result of calling LazyValue()

I'm not wedded to any particular solution, I was just spitballing. I chose Symbol.lazy for its similarity to Symbol.iterator, since I see lazy as sort of iterator-but-with-only-one-value.

It needs to be nice to use though since there will be no way to encapsulate laziness inside a function.

@claudepache you mention:

map.getOr(key, LazyValue(() => new ExpensiveClass))

I'd think that in order for this to work you'd have to have

const LazyValue = fn => ({ [Symbol.lazy]: fn })

LazyValue can't call fn or else it wouldn't be lazy!

@theScottyJam It's a little odd, but I think you'd just write lazy(() => lazy(() => value)). There's nothing recursive going on so nesting works fine.

The complete example would then be:

acceptsLazy(lazy(() => map.getOr(key, lazy(computeExpensiveDefault))));

I ran some v8 microbenchmarks on common empty constructors (x1,000,000):

new Set: 18ms
new Map: 21ms
[]: 1ms
() => {}: 17ms
(() => new Map)(): 24ms

The results indicate that map.getOr(key, new Set()) is just as fast as map.getOr(key, () => new Set()), which to me means that the improved readability of being able to omit the arrow function can often be realized. Significant perf improvements are even achieved when the map values are primitives or arrays!

Yeah, that would work.

I guess my main worry, is that it's nice to have simple APIs that can operate with whatever value you give it, and it just works. You don't have special-case behaviors for special values.

It's one thing I dislike about React's useState() hook, which looks like this:

const [state, setState] = useState(...)

useState() returns a tuple containing the current state, and a function to update the current state (the setState() function). You can call setState() with whatever value to make that the new current state.

With one exception. If you call it with a function, instead of setting the current state as a function, it'll call your function, passing in the old state and expecting you to return a transformed state. This is useful and important behavior, but it's also really annoying that they're overloading the same useState() function to provide it. What if, I want to actually store a function as state, or, I want to store a user-supplied value (I don't care what it is, could be a function, or anything else) as state? This has happened to me before. Well, you can't, because of how this function is set up. You instead have to put the function into a single-property object and store that as the state.

The root of this problem is that they tried to smartly overload a single function to have two different behaviors depending on what was passed in. I worry that creating a "lazy function" will be encouraging many APIs to make this same mistake, where they overload their functions to have two different behaviors depending on what was passed in, and now you can't just use those APIs with whatever random user-supplied value that you received, you have to be cautious because it's possible that user-supplied value is a lazy function, which might trigger special behaviors in the function you're trying to use, and you might not want those special behaviors to be triggered, but there's no good way to opt-out of that.

e.g. I can't just call map.getOr() with some random user-supplied value I received as input, unless I'm ok with map.getOr()'s default treatment of how it handles lazy functions, which it's possible that that's not how I want it to behave.

Yeah that's quite fair really. It feels esoteric issue right now because no lazys yet exist and I'm spitballing purely in syntactic terms, but I think you're right to say that it may become a real issue.

An alternate design which does not suffer from that issue would be adding four methods instead of two:

Map.prototype.getDefault(key, defaultValue);
Map.prototype.getLazyDefault(key, defaultValueFactory);
Map.prototype.setDefault(key, defaultValue);
Map.prototype.setLazyDefault(key, defaultValueFactory);
1 Like