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

refactor(context-async-hooks): fix eslint warnings #5381

Open
wants to merge 2 commits into
base: main
Choose a base branch
from

Conversation

chancancode
Copy link
Contributor

Which problem is this PR solving?

Fix the following eslint warnings in the opentelemetry-context-async-hooks package:

/home/runner/work/opentelemetry-js/opentelemetry-js/packages/opentelemetry-context-async-hooks/src/AbstractAsyncHooksContextManager.ts
  72:35  warning  Don't use `Function` as a type  @typescript-eslint/ban-types
  134:60  warning  Don't use `Function` as a type  @typescript-eslint/ban-types
  152:64  warning  Don't use `Function` as a type  @typescript-eslint/ban-types
  176:15  warning  Don't use `Function` as a type  @typescript-eslint/ban-types

/home/runner/work/opentelemetry-js/opentelemetry-js/packages/opentelemetry-context-async-hooks/src/AsyncHooksContextManager.ts
  49:22  warning  Forbidden non-null assertion  @typescript-eslint/no-non-null-assertion

Short description of the changes

The first set of warnings are more involved, the second one just didn't turn out to be necessary at all and the non-null assertion (the ! postfix operator) can simply be removed.

Explaination:

When using typeof foo === 'function' TypeScript narrows the type of foo to Function, so it may be tempting to use Function in signatures when you want to accept any callable function.

However, this is not quite what Function means. Function as a type in TypeScript has inherited a fair bit of historical baggage and behaves strangely.

For the most part, it only guarentee that it has the Function prototype, so has things like .name, .bind and .call on it, but not much beyond that.

For one thing, it includes things like class constructors which are not callable (not without the new keyword), but TypeScript will still let you call it, but the return value is hardcoded to any. At the same time though, it won't let you assign a type of Function to a signature type (e.g. (...args: any[]) => any)) without an explicit cast.

So generally, Function probably doesn't do what you want, has massive footgun around type safety when called, and should be avoided in favor of a suitable signature type, hence the eslint rule forbidding it.

Notably, depending on the position, this is often one of the few cases where you legitimately have to use any over unknown (for the parameters and/or the return type), or else the variance/ subtyping may not work out the way you want. I think there are possibly pre-exisitng issues regarding this in the files I touched, but in the interest of keeping the PR focused and not leaching changes into public API, I did not address those in this commit.

Ref #5365

Type of change

Please delete options that are not relevant.

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • This change requires a documentation update

How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration

  • TypeScript
  • eslint

Checklist:

  • Followed the style guidelines of this project
  • Unit tests have been added
  • Documentation has been updated

@chancancode chancancode requested a review from a team as a code owner January 27, 2025 22:13
@@ -19,13 +19,17 @@ import { EventEmitter } from 'events';

type Func<T> = (...args: unknown[]) => T;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using unknown here and in the signature of abstract with() is probably wrong:

https://www.typescriptlang.org/play/?#code/CYUwxgNghgTiAEAzArgOzAFwJYHtXwx2BwAoBKALnlRADcQYBuAKGcigGcP4AVACyyoA5vADezePA4Yo2MPADuWDHwCqqANaocC1AB4AgvBAAPDCFTBuaLTtQBtALoAaeADFjZi1fgkAdAGwQhxUBmTwALwAfPAASiAYyDCoPACeAA4gem5RUSSIqFRulHEJSSkZWTliEpIERKRkLJIAvqyS0rJY8koqBqiphp7mltxQA06uHqYjPv6BMMGh4dGliclpmdm5+YXuJfHrFVvV4nX1xOTN8G1tzCjo2HhIOKRQVNIwgkKuAEZUqGQAFtfgwSr9XhAQOMapJCJcmsw7vxvn5empNNpdPlXoiUcI0co+P1UjicIigA

But I am not touching that in this PR. Fixing it on the with signature, in particular, requires some careful analysis, since it's part of the public API. Everything I changed in this PR, in contrast, are private implementation details.

/**
* Store a map for each event of all original listeners and their "patched"
* version. So when a listener is removed by the user, the corresponding
* patched function will be also removed.
*/
interface PatchMap {
[name: string]: WeakMap<Func<void>, Func<void>>;
[name: string | symbol]: WeakMap<Func<void>, Func<void>>;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With better type checking after the refactors, TypeScript caught this as Node EventEmitter event types can be symbols (at least according to the types)

Copy link

codecov bot commented Jan 27, 2025

Codecov Report

Attention: Patch coverage is 97.50000% with 1 line in your changes missing coverage. Please review.

Project coverage is 94.66%. Comparing base (29d0da5) to head (a056ee9).

Files with missing lines Patch % Lines
...sync-hooks/src/AbstractAsyncHooksContextManager.ts 97.43% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #5381      +/-   ##
==========================================
+ Coverage   94.64%   94.66%   +0.01%     
==========================================
  Files         318      318              
  Lines        8033     8042       +9     
  Branches     1688     1688              
==========================================
+ Hits         7603     7613      +10     
+ Misses        430      429       -1     
Files with missing lines Coverage Δ
...ontext-async-hooks/src/AsyncHooksContextManager.ts 100.00% <100.00%> (ø)
...sync-hooks/src/AbstractAsyncHooksContextManager.ts 98.78% <97.43%> (+1.52%) ⬆️

@@ -36,6 +40,12 @@ const ADD_LISTENER_METHODS = [
'prependOnceListener' as const,
];

// 'addListener' | 'on' | 'once' | 'prependListener' | 'prependOnceListener'
type AddListenerKeys = (typeof ADD_LISTENER_METHODS)[number];
Copy link
Contributor Author

@chancancode chancancode Jan 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TypeScript gymnastics:

const FOO: [string, number, boolean] = [...];

type TString = (typeof FOO)[0];
type TNumber = (typeof FOO)[1];
type TBoolean = (typeof FOO)[2];

type TStringOrNumberOrBoolean = (typeof FOO)[number];

*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return contextWrapper as any;
return contextWrapper as T;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We didn't have to cast to any here, since the contextWrapper satisfies the call signature of T (because I typed all the arguments and return type in accordance to the original T).

However, a cast is necessary here (and technically, unsafe) because T can encompass more than just the call signature. For example, if the input function has a custom property on it (fn.foo = "foo fn"), then it will be lost with this wrapper. Probably not an issue in the real world in this case, but TypeScript isn't wrong to flag it.

And, in general, even if we have to do a completely unrelated unsafe cast, casting into unknown and then to the target type would always work, without having to involve any and disabling the lint rule.

context: Context,
ee: T
): T {
private _bindEventEmitter(context: Context, ee: EventEmitter): EventEmitter {
Copy link
Contributor Author

@chancancode chancancode Jan 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accepting <T extends EventEmitter> causes the assignment below to require a cast without really adding much in type safety.

The reason is that if the input is T extends EventEmitter, technically, the signature for the ee[methodName] can be different than EventEmitter[methodName], so that assignment won't work. To make that work requires propagating the generic to everywhere else which adds a lot of noise with little benefit (ultimately it will boil down to an unsafe cast somewhere, involving exactly this issue anyway).

}
const patchedListener = events.get(listener);
return original.call(this, event, patchedListener || listener);
return original.apply(this, args);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I changed all the delegating calls in this file to take ...args and pass them through to the original via apply(), mostly for consistency, but also arguably safer. If Node adds extra optional arguments in the future we will pass them through correctly.

```
/home/runner/work/opentelemetry-js/opentelemetry-js/packages/opentelemetry-context-async-hooks/src/AbstractAsyncHooksContextManager.ts
  72:35  warning  Don't use `Function` as a type  @typescript-eslint/ban-types
  134:60  warning  Don't use `Function` as a type  @typescript-eslint/ban-types
  152:64  warning  Don't use `Function` as a type  @typescript-eslint/ban-types
  176:15  warning  Don't use `Function` as a type  @typescript-eslint/ban-types

/home/runner/work/opentelemetry-js/opentelemetry-js/packages/opentelemetry-context-async-hooks/src/AsyncHooksContextManager.ts
  49:22  warning  Forbidden non-null assertion  @typescript-eslint/no-non-null-assertion
```

The first set of warnings are more involved, the second one just
didn't turn out to be necessary at all and the non-null assertion
(the `!` postfix operator) can simply be removed.

Explaination:

When using `typeof foo === 'function'` TypeScript narrows the type
of `foo` to `Function`, so it may be tempting to use `Function` in
signatures when you want to accept any callable function.

However, this is not quite what `Function` means. `Function` as a
type in TypeScript has inherited a fair bit of historical baggage
and behaves strangely.

For the most part, it only guarentee that it has the `Function`
prototype, so has things like `.name`, `.bind` and `.call` on it,
but not much beyond that.

For one thing, it includes things like class constructors which
are not callable (not without the `new` keyword), but TypeScript
will still let you call it, but the return value is hardcoded to
`any`. At the same time though, it won't let you assign a type of
`Function` to a signature type (e.g. `(...args: any[]) => any)`)
without an explicit cast.

So generally, `Function` probably doesn't do what you want, has
massive footgun around type safety when called, and should be
avoided in favor of a suitable signature type, hence the eslint
rule forbidding it.

Notably, depending on the position, this is often one of the few
cases where you legitimately have to use `any` over `unknown`
(for the parameters and/or the return type), or else the variance/
subtyping may not work out the way you want. I think there are
possibly pre-exisitng issues regarding this in the files I touched,
but in the interest of keeping the PR focused and not leaching
changes into public API, I did not address those in this commit.

Ref open-telemetry#5365
@chancancode chancancode force-pushed the eslint-warning-context-async-hooks branch from 9c3087e to ff79a44 Compare January 27, 2025 22:29
@chancancode chancancode mentioned this pull request Jan 24, 2025
25 tasks
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

Successfully merging this pull request may close these issues.

1 participant