Enforcing 'noImplicitAny' on callbacks to generic functions

gugaguichard

Gustavo Guichard (Guga)

Posted on June 10, 2024

Enforcing 'noImplicitAny' on callbacks to generic functions

One of the first errors every TypeScript developer encounters is the famous noImplicitAny, which happens when you don't specify a type for a variable. For example:

function fn(s) {
  //        ^? Parameter 's' implicitly has an 'any' type.
  console.log(s.subtr(3));
}
Enter fullscreen mode Exit fullscreen mode

This is a great thing because it forces you to think about the types of your variables and functions, helping you avoid bugs.

The problem

When developing your own functions that receive a callback, you might lose that error checking if you specify the type of the callback as (...args: any[]) => any. This type indicates that the function can receive any number of arguments of any type.

// @ts-expect-error
function fn(a) {}
const wrapper = (f: (...args: any[]) => any) => f
const test = wrapper((a) => {})
//                    ^? any
// TS won't complain
Enter fullscreen mode Exit fullscreen mode

If you don't know, (...args: any[]) => any is one of the most popular ways to type a function. The Function primitive is not recommended because it can accept anything that is callable, such as a constructor, a callable object, or even a non-callable class such as Map.
But this use case creates a bad developer experience because you're losing one of the basic benefits of the type system. An any can easily go unnoticed and cause bugs further down the line.

The quest

Last week, I started a Twitter thread about this and asked for help from some friends and TS experts.
I was working on our library composable-functions on an issue by @danielweinmann that was experiencing this problem.

The problem seemed impossible and the solutions would require some trade-offs.

  • Forbidding the use of any in the callback's arguments was reasonable since the library I was working on is for strict TypeScript projects and any is probably out of the question. However, it would be a breaking change for the users and quite strict.
  • Swapping (...args: any[]) => any for (...args: never[]) => unknown. This was suggested by our contributor @jly36963 which is a thread on the TS repo itself. It seems nice because if you don't declare the types, the arguments are gonna be never, so you can't use them within the function. There was one concern though, this solutions causes problems with default arguments - as you can read in the last comment on that thread.

This is when I decided to go bold and try that Function primitive!

The solution

The solution was to use it in the function parameter but restrict it anywhere else down the line. We only need to enforce noImplicitAny in the declaration of the callback, but we have other ways to enforce a narrower type, such as (...args: any[]) => any, elsewhere.

The diff was quite simple:

-function composable<T extends (...args: any[]) => any>(fn: T): Composable<T> {
+function composable<T extends Function>(
+  fn: T,
+): Composable<T extends (...args: any[]) => any ? T : never> {
Enter fullscreen mode Exit fullscreen mode

And it worked! The error was back and the user would have to specify the types of the arguments of the callback.

If the user tried to use something which isn't an actual function, they would get a Composable<never> which is impossible to work with.

You can check the final PR here

Conclusion

There were two motivations to write this post:

  • I couldn't find any information about this on the internet.
  • I love simple solutions that require you to think outside the box and break some rule as long as you cover up for them.

I hope this post can help someone else who is struggling with the same problem.

💖 💪 🙅 🚩
gugaguichard
Gustavo Guichard (Guga)

Posted on June 10, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related