Enforcing 'noImplicitAny' on callbacks to generic functions
Gustavo Guichard (Guga)
Posted on June 10, 2024
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));
}
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
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 andany
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 benever
, 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> {
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.
Posted on June 10, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.