TypeScript icon indicating copy to clipboard operation
TypeScript copied to clipboard

Prevent spreading of promises, as it's likely not intended

Open papermana opened this issue 3 years ago • 8 comments

Suggestion

Spreading a promise in an object is correct Javascript, and Typescript allows it. But it doesn't do anything:

{ ...Promise.resolve({ key: 42 }) } // returns {}

Any time you're trying to spread a promise, it's much more likely that you have an async function that you forgot to await. The following code compiles.

const myAsyncFunc = () => Promise.resolve({ key: 42 });

const result = {
  foo: 'bar',
  ...myAsyncFunc(),
}; 

While this is "correct", it's almost certainly not what the programmer intended.

🔍 Search Terms

Spreading promise.

✅ Viability Checklist

My suggestion meets these guidelines:

  • [ ] This wouldn't be a breaking change in existing TypeScript/JavaScript code
  • [x] This wouldn't change the runtime behavior of existing JavaScript code
  • [x] This could be implemented without emitting different JS based on the types of the expressions
  • [x] This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, new syntax sugar for JS, etc.)
  • [x] This feature would agree with the rest of TypeScript's Design Goals.

(I'm not sure about the first point. This would be a breaking change, but it should only affect code that likely contains a bug. The feature request does support the goal of "Statically identify constructs that are likely to be errors.")

⭐ Suggestion

Prevent code that spreads promises from compiling.

📃 Motivating Example

As it's almost certainly unintended, Typescript should prevent promises from being spread in objects.

💻 Use Cases

This would prevent a bug that I personally faced recently. I can't think of any scenario where someone would intentionally want to spread a promise.

An IDE or a plugin could detect that you're spreading a function marked as async, but I'm not sure whether they would be able to detect that a function returns a promise, or that a function returns the result of another function call that returns a promise.

papermana avatar May 18 '22 08:05 papermana

+1

falinsky avatar May 18 '22 09:05 falinsky

This can be done in a much more generic way if we can tell TS that certain keys are non-enumerable. Spreading an object with all keys non-enumerable is likely a mistake. But... sigh there are a lot of JavaScript nuances that TS can't capture.

Josh-Cena avatar May 18 '22 12:05 Josh-Cena

Issue for what @Josh-Cena mentioned: #9726

MartinJohns avatar May 18 '22 15:05 MartinJohns

Ah, wait, promises don't have any own properties whatsoever—everything's on Promise.prototype. So instead, my comment should be a feature request for TS to understand the prototype chain 😄

TS has a long way to go

Josh-Cena avatar May 18 '22 15:05 Josh-Cena

I think the specific case of disallowing spreading a Promise is very unobjectionable -- it's extremely obviously a mistake, and a refactoring hazard when asyncifying a sync function.

A rule of "disallow spreading if there are zero enumerable properties" is overbroad, because people will sometimes write something like

const opts = useOptions ? options : { };
const x = { ...opts, foo: 3 }

wherein opts gets subtype-reduced to { } and thus has zero enumerable properties.

RyanCavanaugh avatar May 18 '22 18:05 RyanCavanaugh

I guess there should be an eslint rule to forbid spreading promises

JustFly1984 avatar May 18 '22 21:05 JustFly1984

Initially, I thought this was something that would have to be integrated with Typescript, but typescript-eslint can indeed prevent this error. I made a change in this PR. So as soon as that gets published, there should be a good way to work around the issue described here.

papermana avatar May 25 '22 10:05 papermana

I just experienced a weird situation where accidentally spreading a promise would disable the type checking for the return type of an async function. I had to dig into it to find this issue and figure out that there was an Eslint rule for this that wasn't enabled because of a misconfiguration on my end. Here is the code snippet:

async function randomPromise(): Promise<Record<string, string>> {
    return { b: "2" };
}

export async function test(): Promise<Record<string, string>> {
    return {
        a: 1,
        ...randomPromise(),
    };
}

The above snippet passes tsc when it should not. When commenting or awaiting ...randomPromise(),, it correctly displays the error:

src/index.ts:7:9 - error TS2322: Type 'number' is not assignable to type 'string'.

7         a: 1,
          ~

Found 1 error in src/index.ts:7

noomly avatar Oct 18 '22 13:10 noomly