Follow me on Twitter at @tim_deschryver | Subscribe to the Newsletter | Originally published on timdeschryver.dev.
While I was working on a refactor, in the NgRx project, to allow N number of action handlers on
within an NgRx reducer createReducer
- huzzah for variadic tuple types 🥳, I've encountered a TypeScript issue that I've already bumped against to.
The issue is a compile-time error that gives us the message "Argument of type 'interface' is not assignable to parameter of type 'interface'". When you don't read the full error message (like I did) it can take a while to understand the problem, why it happens, and how to solve this error.
From my understanding, the error pops up when there's a wrapper method that:
- uses a generic
- and has a callback argument that works with the same generic
- the callback argument returns a type that contains the same generic
- and the generic types are not 100% identical
The snippet below illustrates the problem in the most simple way.
// The `Callback<T>` interface is used in the `wrapper` method as well as the `callback` method.
interface Callback<T extends object> {
(arg: T): any
}
function wrapper<T extends object>(callback: Callback<T>): any {
return callback({} as T)
}
function callback<T extends object>(cb: () => T): Callback<T> {
return cb
}
These functions give us the following results when we use them:
// --- INVALID --- //
const givesAnError = wrapper<{ prop?: string }>(callback(() => ({ prop: '' })))
// |> Argument of type 'Callback<{ prop: string; }>' is not assignable to parameter of type 'Callback<{ prop?: string | undefined; }>'.
// |> Types of property 'prop' are incompatible.
// |> Type 'string | undefined' is not assignable to type 'string'.
// --- VALID --- //
// Types are 100% identical
const works = wrapper<{ prop: string }>(callback(() => ({ prop: '' })))
// Provide the same generic to the callback method
const worksCallbackGeneric = wrapper<{ prop?: string }>(a
callback<{ prop?: string }>(() => ({ prop: '' })),
)
// A type assertion on the return value of the callback
const worksTypeAssertion = wrapper<{ prop?: string }>(
callback(() => ({ prop: '' } as { prop?: string })),
)
Now the weird part is that when the callback
method accepts an input parameter of the same type, it does compile.
But only when the argument is used.
function callbackWithInput<T extends object>(cb: Callback<T>): Callback<T> {
return cb
}
// --- VALID --- //
// Note the argument `_arg` isn't used but helps to make this compile
const works = wrapper<{ prop?: string }>(
callbackWithInput((_arg) => ({ prop: '' })),
)
// --- INVALID --- //
// Same but without an argument gives the same compile error as before
const stillDoesntWork = wrapper<{ prop?: string }>(
callbackWithInput(() => ({ prop: '' })),
)
// |> Argument of type 'Callback<{ prop: string; }>' is not assignable to parameter of type 'Callback<{ prop?: string | undefined; }>'.
// |> Types of parameters 'input' and 'input' are incompatible.
// |> Type '{ prop?: string | undefined; }' is not assignable to type '{ prop: string; }'
From what I could see, is that somehow TypeScript isn't able to correctly infer the generic's interface anymore.
To be honest with you, I don't know why and I would expect this to compile because the signature has the same types.
As shown in the usage examples above, we can make this work but therefore we must make changes to the signature or the way how the callback method is invoked. From a consumer's perspective, this is bad and this would've been a massive breaking change for the ones using NgRx.
As a fix, I introduced a new generic in the NgRx to help TypeScript with the interference. While at first this seemed like a fix, it introduced a hidden breaking change because the signature of the on
method was changed.
Noteworthy to mention that if you're adding a generic to only use it once, you're probably doing something wrong. This rule "Type Parameters Should Appear Twice" is explained in The Golden Rule of Generics, written by Dan Vanderkam.
Luckily, Alex Okrushko provided a better solution that doesn't impact the consumer.
The solution to resolve this issue correctly is to help TypeScript to infer the type. For this, we can tweak the strictness of the generic. Instead of using the generic directly we can create a mapped type by using keyof
.
interface Callback<T extends object> {
(
input: {
[P in keyof T]?: T[P]
},
): S
}
The other strange part here is that in the NgRx types, Alex didn't need to type the generic's properties as potentially undefined
(as you can see here) while I had to when I created this reproduction.
So while this blog post does leave us with some unclarity, it does provide a solution to this problem.
To play around with this reproduction, see the TypeScript Playground link
Follow me on Twitter at @tim_deschryver | Subscribe to the Newsletter | Originally published on timdeschryver.dev.
Top comments (0)