Trust the typescript compiler
I have been using typescript for some time, and I've repeatedly got one particular error that I have not taken the time to understand until this time of writing.
You may have experienced this error yourself.
It goes a bit like this:
'T' could be instantiated with an arbitrary type which could be unrelated to
<insert type here>
Below is the code that triggers the above error:
interface UserData {
id: string | number;
email: string;
password: string;
}
const store = (): UserData[] => [{
id: 'bob@gmail.com',
email: 'bob@gmail.com',
password: 'Passw0rd',
}];
function createUserService<T extends UserData>() {
return () => {
let users: Iterable<T> = {
*[Symbol.iterator]() { // ERROR: 'T' could be instantiated
// with an arbitrary type
// which could be unrelated to '{ id: any; }'
for (let user of Object.values(store())) {
yield { ...user, id: user.email };
}
}
};
return users;
}
}
Typescript screams bloody murder on line 16, and I initially struggled to see the problem.
Line 16 calls a store
function that returns an array of objects of type UserData
.
The createUserService
function on line 13 takes a type argument T
that has a constraint stating that T
must at the very least conform to UserData
.
If the store
function returns an array of UserData
, why is the compiler being mean?
Later, when I understood this error, there actually were problems with this code block that seems all too obvious now.
Let us start with a simpler example illustrating what is going wrong here to make this easier to understand.
The simplest possible example
Below is the most straightforward possible code that can recreate this error:
const myNumber = 1;
export function createFunction<T extends string | number>() {
let myGeneric: T = myNumber; // <-- 'number' is assignable to the constraint of type 'T',
// but 'T' could be instantiated with a different
// subtype of constraint 'string | number'.
return myGeneric;
}
Now I have a simple example, and I can understand the error message.
The first part reads:
'number' is assignable to the constraint of type 'T',
What this means is that T
and number
both satisfy the constraint for <T extends string | number>
.
but 'T' could be instantiated with a different subtype of constraint 'string | number'.
This line informs us that the compiler also needs a guarantee that number
satisfies T
, which would not be the case if T
were string
in this example:
createFunction<string>();
Eureka! I think I actually understand what is going on!
Here is another simple example that was initially very strange:
function fn<T extends "a">(): T {
return "a" // 'T' could be instantiated with a different subtype of constraint '"a"'
}
The error message seems wrong because "a"
is a subtype of T
that extends "a"
but consider this:
function fn<T extends "a">(): T {
return "a";
}
const result = fn<"a" & { tag: 2 }>().tag // 2
Line 5 calls fn
and passes <"a" & { tag: 2 }>
for type argument T
.
If fn
returns the string literal "a"
then this generic type argument is totally useless and is indeed completely wrong.
Now the error message is crystal clear. If the function returns "a"
then it can never be { tag: 2 }
, and this is why the compiler is being mean.
The compiler is not being mean.
It is protecting us!
Back to the original problem
Let us look again at the first problem:
interface UserData {
id: string | number;
email: string;
password: string;
}
const store = (): UserData[] => [{
id: 'bob@gmail.com',
email: 'bob@gmail.com',
password: 'Passw0rd',
}];
function createUserService<T extends UserData>() {
return () => {
let users: Iterable<T> = {
*[Symbol.iterator]() { // ERROR: 'T' could be instantiated with an arbitrary type....
for (let user of Object.values(store())) {
yield { ...user, id: user.email };
}
}
};
return users;
}
}
The call to store()
on line 7 returns an array of users of type UserData
, but because we are returning an array that only has the fields specified in UserData
, there is no scope to extend it with extra fields.
The array only has these fields, so it will only allow these fields:
{
id: string;
email: string;
password: string;
}
We cannot call it like this:
interface LDAPUserData extends UserData {
dsn: string;
}
const customUserService = createUserService<CustomUserData>();
for (const user of customUserService()) {
console.log(user.dsn) // uh oh, the implementation only returns fixed fields which doesn't contain foo
}
We cannot supply the extra field.
One more example
Here is another interesting example:
const uppercase = <Params extends string | string[]>(params: Params): Params => {
if (typeof params === "string") {
return params.toUpperCase(); // ERROR: 'string' is assignable to the constraint
// of type 'Params', but 'Params' could be instantiated
// with a different subtype of constraint 'string | string[]'.
}
return params.map(param => param.toUpperCase()); // ERROR: 'string[]' is assignable to the constraint
// of type 'Params', but 'Params' could be instantiated
// with a different subtype
// of constraint 'string | string[]'
}
uppercase('hello')
The error message is again surprising until you consider what the uppercase
function returns:
<Params extends string | string[]>(params: Params): Params
It returns the same type as the type argument Params
.
so when you call uppercase('hello')
, the Params
type argument gets replaced with hello
like this:
const uppercase: <"hello">(params: "hello") => "hello";
The return type is hello
which is the same type that is passed to the uppercase
function, which is lowercase hello
. The typing is wrong, and the compiler is correct to let us know that this could potentially be unsound.
Epilogue
I finally understand the error message that I initially thought was pedantic.
The error message is pointing to some very clear problems with the code and I will write more robust software if I pay attention.