The following interface and function, with defaults for K
, V
and T
:
interface IBST<K = number, V = string> {
key: K;
value: V;
parent?: this;
left?: this;
right?: this;
}
function *walkInOrder<K = number, V = string, T extends IBST<K, V> = IBST<K, V>>(tree?: T): Generator<T> {
if (tree === undefined) return;
yield* walkInOrder(tree.left);
yield tree;
yield* walkInOrder(tree.right);
}
Causes the following error where walkInOrder
calls itself recursively (precisely for the expressions walkInOrder(tree.left)
and walkInOrder(tree.right)
):
Type 'IBST<number, string>' is not assignable to type 'T'. 'T' could be instantiated with an arbitrary type which could be unrelated to 'IBST<number, string>'.ts(2322)
I'm surprised by this because I thought these defaults would be used as a fallback for when the types of K and V can't be inferred from their context, but now it seems to me like generic defaults are not a fallback but something else. What's a good way to think about this situation and generic variable defaults in general?
The call signature
<K, V, T extends IBST<K, V> = IBST<K, V>>(tree?: T) => Generator<T>
is fairly problematic because there are no inference sites for K
or V
. When you call that function, the type of T
can be inferred from the argument for tree
(assuming it's passed in at all), but nothing is available from which to infer K
or V
. You might think that the constraint for T
could serve as such an inference site, but TypeScript doesn't work that way. See microsoft/TypeScript#7234. There are too many generic type parameters for this function.
So whenever you call that function, unless you manually specify the type arguments, inference will fail for K
and V
, and they will fall back to something. If K
and V
are given default type arguments, they will fall back to those defaults. Otherwise they will fall back to their constraints. Since there are no explicit constraints, they will fall back to their implicit constraint of unknown
.
So, let's look at those recursive calls:
Given
function* walkInOrder<K, V, T extends IBST<K, V> = IBST<K, V>>(
tree?: T): Generator<T> {
if (tree === undefined) return;
yield* walkInOrder(tree.left);
yield tree;
yield* walkInOrder(tree.right);
}
the type of tree.left
and tree.right
are T | undefined
, and so in those calls, the T
type argument for the inner call is inferred as the same as the T
type parameter for the outer call. K
and V
cannot be inferred, and so they fall back to unknown
. This is like calling walkInOrder<unknown, unknown, T>(tree.left)
. The constraint for T
is therefore IBST<unknown, unknown>
. That is a supertype of IBST<K, V>
no matter what K
and V
are, so it succeeds.
On the other hand, given
function* walkInOrder<K = number, V = string, T extends IBST<K, V> = IBST<K, V>>(
tree?: T): Generator<T> {
if (tree === undefined) return;
yield* walkInOrder(tree.left);
yield tree;
yield* walkInOrder(tree.right);
}
the same T
is inferred for the inner calls, but now K
and V
fall back to string
and number
respectively. This is like calling walkInOrder<string, number, T>(tree. left)
. The constraint for T
is therefore IBST<string, number>
. This is * not* known to be a supertype of IBST<K, V>
, so it fails. Once it fails, T
falls back to its constraint, and now the inner walkInOrder()
returns Generator<IBST<string, number>>
, which is (for the same reason) not known to be assignable to Generator<T>
.
So that's what's going on. I'd say the issue here has little to do with the defaults and everything to do with the fact that K
and V
cannot be inferred. If you want to make this work, you can explicitly specify K
and V
in the inner calls, like
function* walkInOrder<K = number, V = string, T extends IBST<K, V> = IBST<K, V>>(tree?: T): Generator<T> {
if (tree === undefined) return;
yield* walkInOrder<K, V, T>(tree.left);
yield tree;
yield* walkInOrder<K, V, T>(tree.right);
}
But you'll just run into the same problem when you try to call walkInOrder()
outside the call, unless you specify K
and V
manually, and it's not clear what purpose K
and V
actually serve since the output type is Generator<T>
which doesn't care about K
and V
at all. You might as well either remove K
and V
and compute everything from T
, or remove T
and compute everything from K
and V
. This works well:
function* walkInOrder<K = number, V = string>(tree?: IBST<K, V>): Generator<IBST<K, V>> {
if (tree === undefined) return;
yield* walkInOrder(tree.left);
yield tree;
yield* walkInOrder(tree.right);
}