Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Rule proposal: functions should not be async unless they await #9284

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

Closed
6 tasks done
benmccann opened this issue Jun 5, 2024 · 15 comments
Closed
6 tasks done

Rule proposal: functions should not be async unless they await #9284

benmccann opened this issue Jun 5, 2024 · 15 comments
Labels
enhancement: new plugin rule New rule request for eslint-plugin locked due to age Please open a new issue if you'd like to say more. See https://typescript-eslint.io/contributing. package: eslint-plugin Issues related to @typescript-eslint/eslint-plugin wontfix This will not be worked on

Comments

@benmccann
Copy link
Contributor

benmccann commented Jun 5, 2024

Before You File a Proposal Please Confirm You Have Done The Following...

My proposal is suitable for this project

  • My proposal specifically checks TypeScript syntax, or it proposes a check that requires type information to be accurate.
  • My proposal is not a "formatting rule"; meaning it does not just enforce how code is formatted (whitespace, brace placement, etc).
  • I believe my proposal would be useful to the broader TypeScript community (meaning it is not a niche proposal).

Description

The rule would check that we don't cause unnecessary performance losses. The async keyword should not be used where TypeScript can already determine that a Promise is being returned and there is no await as adding async in that case will only cause a slight performance hit

Fail Cases

/**
 * @param {RegExp} re
 * @param {(...match: any[]) => Promise<MappedCode>} get_replacement
 * @param {string} source
 */
async function calculate_replacements(re, get_replacement, source) {
	/**
	 * @type {Array<Promise<import('./private.js').Replacement>>}
	 */
	const replacements = [];
	source.replace(re, (...match) => {
		replacements.push(
			get_replacement(...match).then((replacement) => {
				const matched_string = match[0];
				const offset = match[match.length - 2];
				return { offset, length: matched_string.length, replacement };
			})
		);
		return '';
	});
	return Promise.all(replacements);
}

Pass Cases

From https://github.com/sveltejs/svelte/blob/862949d22abf2996594b4315c04fc97e18bc4408/packages/svelte/src/compiler/preprocess/replace_in_code.js#L23:

/**
 * @param {RegExp} re
 * @param {(...match: any[]) => Promise<MappedCode>} get_replacement
 * @param {string} source
 */
function calculate_replacements(re, get_replacement, source) {
	/**
	 * @type {Array<Promise<import('./private.js').Replacement>>}
	 */
	const replacements = [];
	source.replace(re, (...match) => {
		replacements.push(
			get_replacement(...match).then((replacement) => {
				const matched_string = match[0];
				const offset = match[match.length - 2];
				return { offset, length: matched_string.length, replacement };
			})
		);
		return '';
	});
	return Promise.all(replacements);
}

Additional Info

There is https://typescript-eslint.io/rules/promise-function-async, but it's almost exactly opposite of this. I recently turned on a handful of promise-related rules in sveltejs/svelte, but got a ton of pushback on @typescript-eslint/promise-function-async. The consensus seemed to be that the rule seemed like bad practice. The description of the rule in the docs doesn't seem to match at all what it actually does. I think either its docs could be improved or that rule could be removed

@benmccann benmccann added enhancement: new plugin rule New rule request for eslint-plugin package: eslint-plugin Issues related to @typescript-eslint/eslint-plugin triage Waiting for team members to take a look labels Jun 5, 2024
@kirkwaiblinger
Copy link
Member

kirkwaiblinger commented Jun 5, 2024

Hi @benmccann!

  • we have require-await, which bans async-without-await, unless a promise is returned.
  • if you really want to ban async without await regardless of promise returns, technically, you could just use the base rule rather than the typescript-eslint version. That rule exists and does what you are describing, though I personally recommend against it.
  • Coincidentally, i was looking at the docs page for promise-function-async a few minutes ago, and had the exact same thought, that it sorely needs to be updated. Maybe there's an open issue for that already, haven't checked yet.

@kirkwaiblinger kirkwaiblinger added awaiting response Issues waiting for a reply from the OP or another party and removed triage Waiting for team members to take a look labels Jun 5, 2024
@Josh-Cena
Copy link
Member

The consensus seemed to be that the rule seemed like bad practice.

Do you have specific examples in mind? I agree that the docs are not good and the rule has a lot of edge cases (I was recently working on this and... I don't really want to think deeply about all the edge cases that it would have failed on). However I think the rule generally is a good idea since it prevents Zalgo.

@benmccann
Copy link
Contributor Author

you could just use the base rule rather than the typescript-eslint version. That rule exists and does what you are describing, though I personally recommend against it.

It doesn't quite do what I'm describing, unfortunately. It doesn't check that a Promise would be returned without the async. That's why it would need to live in typescript-eslint rather than base. E.g. it triggers on the following in the Svelte codebase:

/**
 * @template V
 * @param {V} value
 * @param {() => Promise<V>} fallback lazy because could contain side effects
 * @returns {Promise<V>}
 */
export async function value_or_fallback_async(value, fallback) {
	return value === undefined ? fallback() : value;
}

However, if you get rid of the async then a Promise is no longer being returned (it may return value, which isn't necessarily a Promise).

The rule I'm describing shouldn't trigger on such a function. If you remove the async above it changes the behavior of the function. I'm suggesting to remove it only when it adds unnecessary performance overhead and doesn't offer any value in terms of behavior.

Do you have specific examples in mind?

Yes, the one in the issue description is the one we examined most closely. There's a link there to the Svelte codebase in case you want to see the code in context.

If it's helpful to have more examples, I believe that for all of the places it suggested adding async the feeling was that it would not provide value though I don't know if the others were examined quite as closely:

│ packages/svelte/src/compiler/preprocess/replace_in_code.js
│   23:1  error  Functions that return promises must be async  @typescript-eslint/promise-function-async
│ packages/svelte/src/motion/spring.js
│    80:2  error  Functions that return promises must be async  @typescript-eslint/promise-function-async
│   131:3  error  Functions that return promises must be async  @typescript-eslint/promise-function-async
│ packages/svelte/src/motion/tweened.js
│    91:2  error  Functions that return promises must be async  @typescript-eslint/promise-function-async
│   148:3  error  Functions that return promises must be async  @typescript-eslint/promise-function-async

However I think the rule generally is a good idea since it prevents Zalgo.

I'm not sure what that means.

@kirkwaiblinger kirkwaiblinger added triage Waiting for team members to take a look and removed awaiting response Issues waiting for a reply from the OP or another party labels Jun 5, 2024
@kirkwaiblinger
Copy link
Member

kirkwaiblinger commented Jun 5, 2024

you could just use the base rule rather than the typescript-eslint version. That rule exists and does what you are describing, though I personally recommend against it.

It doesn't quite do what I'm describing, unfortunately. It doesn't check that a Promise would be returned without the async. That's why it would need to live in typescript-eslint rather than base.

Ah, ok, thanks for clarifying! I misunderstood the details there.

I'm suggesting to remove it only when it adds unnecessary performance overhead and doesn't offer any value in terms of behavior.

I'm not familiar with the performance implication. Are you aware of a reference for that? There is a slight benefit behaviorally for the rejection case, though, see

function promiseFunction() {
   if (Math.random() < 0.3) {
       throw new Error('thrown error');
   } else if (Math.random() < 0.5) {
       return Promise.resolve('resolved value');
   } else {
       return Promise.reject('rejected error');
   }
}

async function asyncFunction() {
   if (Math.random() < 0.3) {
       throw new Error('thrown error');
   } else if (Math.random() < 0.5) {
       return Promise.resolve('resolved value');
   } else {
       return Promise.reject('rejected error');
   }
}

function callAsyncFunction() {
   // this will never throw an exception. 
   // All errors will be logged to the console in the rejection handler.
   asyncFunction().then(console.log, console.error);
   
   // a third of the time exceptions will be synchronously caught
   // a third of the time an exception will be logged in the rejection handler.
   try {
       promiseFunction().then(console.log, console.error);
   } catch (e) {
       console.error(e);
   }
}

EDIT - slight typos in the code

@kirkwaiblinger
Copy link
Member

I am also curious, under the premise that the async-without-await is harmful, wouldn't that imply that you'd anyways want to fix

async function promiseOrValueFunction(): Promise<number> {
    if (Math.random() < 0.5) {
        return 3;
    } else {
        return Promise.resolve(4);
    }
}

to

function promiseOrValueFunction(): Promise<number> {
    if (Math.random() < 0.5) {
        return Promise.resolve(3);
    } else {
        return Promise.resolve(4);
    }
}

rather than let the linter allow that case? (which, you could use the base rule to achieve. You'd have to manually wrap the value returns, but an explicit return type would solve that problem for you)

@kirkwaiblinger
Copy link
Member

However I think the rule generally is a good idea since it prevents Zalgo.

I'm not sure what that means.

I am also curious to know what this means lol 😆

@benmccann
Copy link
Contributor Author

I'm not familiar with the performance implication. Are you aware of a reference for that?

I can't find a reference for it, but three separate Svelte maintainers who are all better versed in JS than myself agreed. Some excerpts from conversations about it below.

this will result in four promises being created rather than two:

async function get(thing) {
  return fetch(`${base}/${thing}`).then(async (r) => r.json());
}

and the evidence is where it gets shotty, cuz it's V8 source and as soon as you start relying on V8 behavior/characteristics it's "read the source" or "wait for v8 blog post"
but the overhead is that it's creating new microtasks, states, and all the function handler plumbing to make sure tasks are queued, executed, and torn down. still all has to happen even if the promise immediately returns and/or does

I am also curious, under the premise that the async-without-await is harmful, wouldn't that imply that...
which, you could use the base rule to achieve

Ah, maybe the base rule is preferable here actually to what I was proposing. Though that also makes me wonder if the @typescript-eslint/require-await behavior should just be that of the base rule by default

@kirkwaiblinger
Copy link
Member

I can't find a reference for it, but three separate Svelte maintainers who are all better versed in JS than myself agreed. Some excerpts from conversations about it below.

this will result in four promises being created rather than two:

async function get(thing) {
  return fetch(`${base}/${thing}`).then(async (r) => r.json());
}

and the evidence is where it gets shotty, cuz it's V8 source and as soon as you start relying on V8 behavior/characteristics it's "read the source" or "wait for v8 blog post" but the overhead is that it's creating new microtasks, states, and all the function handler plumbing to make sure tasks are queued, executed, and torn down. still all has to happen even if the promise immediately returns and/or does

Yeah, the reason I mention a reference is that claims around performance remind me of this, related issue for one of the eslint base rules, eslint/eslint#18166, which is... controversial at best 🙂. And my guess is that that would be relatively unlikely to be a deciding factor in rule logic unless there were a pretty extreme difference.

Ah, maybe the base rule is preferable here actually to what I was proposing.

Hope this has been helpful in addressing your request! 🙂

Though that also makes me wonder if the @typescript-eslint/require-await behavior should just be that of the base rule by default

Eh, even if we granted for the sake of argument that the behavior of the base rule were preferable, the extension uses type information, so what you'd have then is just a significantly-harder-to-configure, worse version of the base rule. Probably would make more sense to just document it as "when not to use" and remark that some find the base rule preferable.

@kirkwaiblinger kirkwaiblinger closed this as not planned Won't fix, can't repro, duplicate, stale Jun 5, 2024
@kirkwaiblinger kirkwaiblinger added wontfix This will not be worked on and removed triage Waiting for team members to take a look labels Jun 5, 2024
@benmccann
Copy link
Contributor Author

Hope this has been helpful in addressing your request! 🙂

yes, thank you!!

the extension uses type information

In general that's super valuable, but for this particular rule I'm not sure the type information can be used in a way that results in a more valuable rule

what you'd have then is just a significantly-harder-to-configure, worse version of the base rule

Why would it be harder to configure? The version in this repo uses the options from the base rule, so it seems like the configuration is the same in either case

@kirkwaiblinger
Copy link
Member

Why would it be harder to configure?

I just mean, configuring typed linting in the first place is a hassle, since you have to supply the tsconfig and all that. It's much easier to get through the one-time-setup for an eslint core rule or non-type-aware rule. Though, of course, if you have any type-aware rules already successfully running, the marginal cost is zero for additional ones.

@kirkwaiblinger
Copy link
Member

not to mention potential runtime costs. Short version - if a rule can be written without type information, we significantly prefer to do so.

@bradzacher
Copy link
Member

From the perf side of things - people very much overstate the impact.

Removing the async is a nano-optimisation - you're really talking about shaving off a few nanoseconds from an invocation. As in you need to be doing such operations a million times before you impact perf by a millisecond.

I fully understand the style and the want to be as efficient as possible! But it's not really a perf concern.

@benmccann
Copy link
Contributor Author

Ah, I think there was some confusion because when I said "though that also makes me wonder if the @typescript-eslint/require-await behavior should just be that of the base rule by default" then I was really asking if we even need @typescript-eslint/require-await at all

But yeah, I think the typescript version actually is better here in most cases as it's less restrictive in how the code is written. In the Svelte codebase there's very little difference between the two rules so I didn't appreciate that initially, but I tried applying the base rule in another project and some places it was a little annoying to have to switch the code in places where performance doesn't matter like tests. And of course even in the main code it's admittedly a micro-optimization. So thanks for talking through this and I agree with the conclusion of closing it

@Josh-Cena
Copy link
Member

I'm not sure what that means.

This is what I mean: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Using_promises#timing, https://blog.izs.me/2013/08/designing-apis-for-asynchrony/. You should not have a function that's "sometimes synchronous, sometimes asynchronous" because that makes it hard to orchestrate task order.

async function get(thing) {
  return fetch(`${base}/${thing}`).then(async (r) => r.json());
}

Why not

async function get(thing) {
  const res = await fetch(`${base}/${thing}`);
  return await r.json();
}

? Also you might be overthinking about the impact of rewrapping promises—engines are very good at optimizing that away.

@bradzacher
Copy link
Member

bradzacher commented Jun 6, 2024

Just cos I was curious. A perf comparison of having the async

  • no async = ~3.2m ops/s
  • with async = ~2m ops/s
  • with async + await = ~1.5m ops/s

if you're downlevelling:

  • with async = ~400k ops/s
  • with async + await = ~300k ops/s

Which illustrates the perf difference. However that difference is slightly worse than I originally mentioned - but it's still on the order of microseconds (one one millionth of a second). You lose 1.2m ops/s by having an unnecessary await - so it's ~1.2 microseconds slower.

Unless you're downlevelling to <ES2017 - if you're doing that then the polyfilled async/await is much slower. Downlevelling to <ES2015 is even worse as it also polyfills generators and you get half the perf again.

@github-actions github-actions bot added the locked due to age Please open a new issue if you'd like to say more. See https://typescript-eslint.io/contributing. label Jun 14, 2024
@github-actions github-actions bot locked as resolved and limited conversation to collaborators Jun 14, 2024
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
enhancement: new plugin rule New rule request for eslint-plugin locked due to age Please open a new issue if you'd like to say more. See https://typescript-eslint.io/contributing. package: eslint-plugin Issues related to @typescript-eslint/eslint-plugin wontfix This will not be worked on
Projects
None yet
Development

No branches or pull requests

4 participants