Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Why does the fetch function return void ? #302

Closed
HuyAms opened this issue May 31, 2023 · 18 comments
Closed

Why does the fetch function return void ? #302

HuyAms opened this issue May 31, 2023 · 18 comments

Comments

@HuyAms
Copy link

HuyAms commented May 31, 2023

Hi,

I'm on version ^9.1.1

I try to use the fetch()function with Typescript. However, the return type is void | V. I expect it to return only the Value type only. The void type will break some of my TS.

Version 7.18return the value only without void

const cache = new LRUCache<string, string> ({
		max: 10,
		ttl: 1000 * 60 * 10, // 10 minutes
		allowStale: true,
		noDeleteOnFetchRejection: true,
		fetchMethod: async (cacheKey) => {

			return "some string"
}))

// I expect it returns string but it returns void | string. Why so?
const data = await cache.fetch("somekey") 
@Thenkei
Copy link
Contributor

Thenkei commented May 31, 2023

I just run into the same issue. Worst case scenario it should be undefined | V isn't it?

@isaacs
Copy link
Owner

isaacs commented May 31, 2023

If the fetch is aborted due to the key being evicted or deleted prior to the fetchMethod returning, then it might not return a value.

It's an edge case, but it is possible, and the previous declared type was incorrect by not including it.

For full type safety, test that the return type is the type you want, and handle the error case. If you know that can't happen and wanna yolo it, you can coerce it with an explicit as.

@Thenkei
Copy link
Contributor

Thenkei commented May 31, 2023

If you know that can't happen and wanna yolo it, you can coerce it with an explicit as.

Indeed that is what I've done in my case but to be fair it doesn't seem like a really good practice. If you Lru-cache change the behavior (and the returned types) of the fetch method I won't be warned.

Could returning undefined or null in this case make everyone happy?

@isaacs
Copy link
Owner

isaacs commented May 31, 2023

Well, the issue is that the promise might resolve without a value if the fetchMethod doesn't return a value when it's aborted. Which, there's no reason to require it to return undefined rather than void, especially since JavaScript doesn't even make a distinction between the two.

What problem does it cause to have void there?

At the very least, removing the void would be a semver major change.

Here's an example:

import { LRUCache } from './'
const c = new LRUCache<number, string>({
  max: 3,
  fetchMethod: async (key, _oldValue, { signal }) => {
    await new Promise<void>(res => setTimeout(() => res(), 100))
    if (signal.aborted) {
      console.error('aborted, returning void', key)
      // this would have to be `return undefined`, whereas `return` is allowed now,
      // because fetch() resolves to the promise that the fetchMethod returns.
      return
    }
    return String(key)
  },
  ignoreFetchAbort: true,
  allowStaleOnFetchAbort: true,
})

Promise.all([
  c.fetch(1),
  c.fetch(2),
  c.fetch(3),
  c.fetch(4),
]).then(console.log)
$ ts-node fetch-void.ts 
aborted, returning void 1
[ undefined, '2', '3', '4' ]

@Thenkei
Copy link
Contributor

Thenkei commented Jun 1, 2023

What problem does it cause to have void there?

That's just an issue when writing pure TS but I guess that void is more appropriate when nothing occurs (aborted).

Monkey patch - We don't want that fetch return void but undefined instead

@HuyAms we can use a monkey patch like this in our case.

class LRUCacheMonkeyPatched<K extends {}, V extends {}, FC extends unknown = unknown> extends LRUCache<K, V, FC> {
  override async fetch(
    k: K,
  ): Promise<undefined | V> {
    const data = await super.fetch(k as never);
    
    // When we fetch void let's return undefined
    if (typeof data === 'undefined') {
      return undefined;
    }

    return data;
  }
}

Or even more straight forward

 override async fetch(
    k: K,
  ): Promise<undefined | V> {
    return super.fetch(k as never) as unknown as undefined | V;
  }

@isaacs
Copy link
Owner

isaacs commented Jun 1, 2023

I write pure TS, but what's the issue, exactly? What situation are you using it in, and what error occurs? I'm ok with making it undefined instead of void, just want to understand why.

The reason I'm pushing back a bit on this is that it means changing the type signature of a public method in an incompatible way, so it's a SemVer breaking change, as it may require downstream users to update their code.

@Thenkei
Copy link
Contributor

Thenkei commented Jun 1, 2023

I hit the issue working on this PR for an open-source project. I ended up doing the yolo mode (coercing it with an explicit as), but that's not our standard/guidelines.

If we don't use the explicit cast. We can't use the optional chaining operator to explicitly say "That's ok, this value might be null or undefined".

image
permissions has just been fetched from the LRU cache. If for some normal reason or obscure reason, it doesn't resolve an expected value we handle this using some optional chaining operator

@ghusse
Copy link

ghusse commented Jun 5, 2023

According to the TS documentation:

void represents the return value of functions which don’t return a value. It’s the inferred type any time a function doesn’t have any return statements, or doesn’t return any explicit value from those return statements:

The fetch function has multiple return statements, and every one of them is returning a value. The keyword void is not appropriate here according to the documentation.

The problem with void when using this function is that TS does not treat it as if it was undefined, even if in JS there is no difference. So in this case void | V is not equivalent to undefined | V and it does not cast easily to this type.

@isaacs
Copy link
Owner

isaacs commented Jun 5, 2023

@ghusse It doesn't return actual void, it returns Promise<V | undefined | void>, and there is a code path where it returns the return value of fetchMethod, which may be a Promise<void>. "Return" is being used as a shorthand for "return a promise that resolves to", because it's an async function.

@dennyok
Copy link

dennyok commented Jun 6, 2023

Hey there. I see the reason for having void as one of the possible promise outcomes. However, testing whether an actual value is returned becomes a bit harder as we cannot simply check for undefined. Instead, we have to check for what we expect (for example, an object). At least I had some problems satisfying TS in this regard.

Below is an example of a simple wrapper which takes a function, initialises a cache and returns a cached version of the input function (same signature). Upon void, an error is thrown, assuming we don't expect aborts and the like. This may help.

export function memoizeLru<Fn extends (...args: Args) => Promise<Result>, Args extends any[], Result extends object>(
  fn: Fn,
  options:
    | Omit<LRUCache.OptionsMaxLimit<string, Result, { args: Args }>, 'fetchMethod'>
    | Omit<LRUCache.OptionsTTLLimit<string, Result, { args: Args }>, 'fetchMethod'>,
  resolveKey: (...args: Args) => string
): (...args: Args) => Promise<Result> {
  const cache = new LRUCache<string, Result, { args: Args }>({
    ...options,
    fetchMethod: async (_key, _staleValue, { context }) => {
      return await fn(...context.args);
    }
  });

  return async (...args: Args) => {
    const result = await cache.fetch(resolveKey(...args), { context: { args } });
    if (!(result instanceof Object)) {
      // result might be of type void (in case of abort)
      // should never happen here (we use no signals)
      throw new Error('fetch aborted');
    }
    return result;
  };
}

Hey, and thanks for this great lib ❤️

@ghusse
Copy link

ghusse commented Jun 6, 2023

The TS doc is pretty clear about the usage of void : as soon as a function CAN return something, void should not be used

@dennyok
Copy link

dennyok commented Jun 6, 2023

@ghusse There is a difference between returning void and Promise<void> and Promise<void|X|Y>. Apart from that, whether fn's return type is void or contains void solely depends on what TS infers based on the paths and return statements of the function, which means that you cannot choose to use void or not. However, you can choose not to mix returns without a value and returns with a value in the same function. If that's what you mean, I haven't seen a statement in the TS docs which forbids this. Maybe there is a linter rule out there ...

@isaacs
Copy link
Owner

isaacs commented Jun 6, 2023

Ok, so, semantic details about what specifically void means, when to use it, whether it's always/never/ever a good idea, etc, is kind of off topic here.

The point is, LRUCache.fetch() can return Promise<void> because it returns the promise returned by the fetchMethod option, and that can return Promise<void>. It's not an ideological point worth debating, tbh, it was just allowed in the JS version for the function to not return anything when it receives an abort signal, and it got ported through to the TS version. TS is weird about this, it is what it is.

It's not super hard to make it Promise<V | undefined> without changing the fetchMethod signature qv. But it does of course change the fetch() signature, so it's a semver major change.

It'll be fixed in version 10. I've been dragging my feet just because I wanted to make sure it's worth the disruption when bumping major on a project like this, and also because I'm feeling lazy, but I guess it's time to update all of my projects again lol.

@ghusse
Copy link

ghusse commented Jun 7, 2023

👍 thanks a lot for the update

@isaacs
Copy link
Owner

isaacs commented Jun 15, 2023

Switched to Promise<V | undefined> in v10.

@isaacs isaacs closed this as completed Jun 15, 2023
@alexgleason
Copy link
Contributor

Why doesn't fetch() just throw instead of returning undefined? Typically you will have to catch it anyway, since validation inside the fetchMethod could throw. It's also how the native Fetch API works. So, having to catch the result and check that the value is undefined is unintuitive.

@isaacs
Copy link
Owner

isaacs commented Jan 22, 2024

@alexgleason In many cases, it does throw (well, reject) when the fetch is aborted. But ignoreFetchAbort and allowStaleOnFetchAbort are both options that change this behavior, and can make it more like a cache.get() when the value is not in the cache (or is stale).

@isaacs
Copy link
Owner

isaacs commented Jan 22, 2024

Or, for example, if you do ignoreFetchAbort and allowStaleOnFetchAbort, then you might make a request, that request times out, and the returned promise resolves to the stale value. Later on, the request is aborted explicitly by the caller, but the promise has already been resolved, etc.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

6 participants