Issue
For the following code, I'm using the arithmetic operation types written in Ryan Dabler's Medium post.
The following snippet produces a compiler error
// Test interface using the Add<A, B> type
interface Foo<Sum extends number> {
increment(): Add<Sum, 1>;
incrementString(): `${Add<Sum, 1>}`;
/* ~~~~~~~~~~~
Type 'Length<[...BuildTuple<Sum, []>, any]>' is not assignable to type 'string | number | bigint | boolean | null | undefined'.
Type 'unknown' is not assignable to type 'string | number | bigint | boolean | null | undefined'.(2322) */
sumParam(param: `${Add<Sum, 1>}`): void;
/* ~~~~~~~~~~~ Same error */
}
Type 'Length<[...BuildTuple<Sum, []>, any]>' is not assignable to type 'string | number | bigint | boolean | null | undefined'. Type 'unknown' is not assignable to type 'string | number | bigint | boolean | null | undefined'.(2322)
Why am I getting this error? From what I can understand, the compiler can resolve Add<Sum, 1>
but not `${Add<Sum, 1>}`
when in a function inside an interface
(or type
, for that matter).
Here are some other tests with unexpected results, the code above preceding.
// Testing types
type One = Add<0, 1> // 1
type OneString = `${Add<0, 1>}` // "1" - The compiler doesn't report an error. Why does it work now but not before?
// Testing with object
// I can declare all the functions even though there was an error before
const bar: Foo<0> = {
increment: () => 1,
incrementString: () => '1',
sumParam: (param: '1') => {},
}
// Testing the functions
// They all run with the expected result even though there was an error before
const baz = bar.increment() // 1
const qux = bar.incrementString() // "1"
const quux = bar.sumParam('1'); // void
Here is the full code in TS playground. I tested it there and on my machine.
Solution
The TypeScript type checker doesn't have the advantage of human intelligence or the ability to look at the names of utility types as a hint to what they are doing. Given
type Add<A extends number, B extends number> =
Length<[...BuildTuple<A>, ...BuildTuple<B>]>;
when you write, say, Add<2, 3>
, the compiler just goes ahead and evaluates it, producing the numeric literal type 5
. But if you write Add<Sum, 1>
where Sum
is a generic type parameter constrained to number
, then the compiler doesn't know what to do with it. It can't directly evaluate it, since it doesn't know what Sum
is. So it leaves it unevaluated and essentially opaque.
It also can't look at Add<Sum, 1>
and say, "well, given the definition of Add
, and the definition of BuildTuple
, and the definition of Length
, I know that whatever Add<Sum, 1>
is, it will be assignable to number
." That would require higher order reasoning abilities possibly aided by the number-suggestive names Add
and Length
. It just doesn't know. And therefore, if you put Add<Sum, 1>
in a type position that is required to extend number
(or string | number | bigint | boolean | null | undefined
), the compiler will complain because it cannot be sure that it works.
The easiest way to proceed in cases like this, where you know that a type X
is assignable to a type Y
but the compiler doesn't, is to replace X
with something like X & Y
(the intersection) or Extract<X, Y>
(the Extract
utility type). The compiler knows that X & Y
is assignable to both X
and Y
, and if you know that X
is assignable to Y
, then X & Y
is equivalent to just X
. Concretely:
interface Foo<Sum extends number> {
increment(): Add<Sum, 1>;
incrementString(): `${number & Add<Sum, 1>}`; // okay
sumParam(param: `${number & Add<Sum, 1>}`): void; // okay
}
This fixes the error because it sidesteps the issue. Now the compiler doesn't have to know that Add<Sum, 1>
is a number
, because whatever Add<Sum, 1>
is, the type number & Add<Sum, 1>
is definitely a number
. As long as we are correct in our assumption that Add<Sum, 1>
will be a number
of some kind, the additional number &
will have no effect on the types that are eventually evaluated.
Answered By - jcalz
0 comments:
Post a Comment
Note: Only a member of this blog may post a comment.