Reputation: 2678
I have an object which has string and number property values. When I want to set value of certain field TypeScript fails at checking property types when using indexOf
method for checking.
Here is example:
type TSomeObject = {
title: string,
description: string,
count: number
}
const someFunction = (myObj: TSomeObject, field: keyof TSomeObject, newValue: string): TSomeObject => {
if (field === 'title' || field === 'description') {
myObj[field] = newValue
}
return myObj
}
const otherFunction = (myObj: TSomeObject, field: keyof TSomeObject, newValue: string): TSomeObject =>{
const editableFields = ['title', 'description']
if (editableFields.indexOf(field) >= 0) {
// TypeScript error here
myObj[field] = newValue
}
return myObj
}
See this example in TS playground.
While someFunction
works perfectly fine, otherFunction
fails with TypeScript error:
Type 'string' is not assignable to type 'never'.
I know it is related to count
property which is of type number
, but hey I checked which field am I editing. What am I doing wrong here?
Upvotes: 3
Views: 661
Reputation: 327654
A few things are going on here.
One is that editableFields
is inferred as string[]
, which is too wide for your purposes. Since TS3.4, you can use a const
assertion to infer narrower types for literals:
const editableFields = ['title', 'description'] as const;
// const editableFields: readonly ["title", "description"]
Now editableFields
is a tuple whose members are known to be "title"
and "description"
.
Next, TypeScript doesn't take the result of arr.indexOf(x) >= 0
to be a type guard on the type of x
. So even if the test is true
, the type of x
is not narrowed to typeof arr[number]
automatically. This is just a limitation of TypeScript; not every possible way of testing for something can be detected by the compiler. Luckily, TypeScript gives you the ability to make user-defined type guard functions. In this case, you might want to make something like this:
declare function arrayContains<T extends U, U>(
haystack: ReadonlyArray<T>,
needle: U
): needle is T;
(I'm using ReadonlyArray
instead of Array
because it's more general. Every Array
is a ReadonlyArray
but not vice versa. Yes, it's a weird name, since ReadonlyArray
might not be read-only; perhaps ReadonlyArray
should be DefinitelyReadableButNotNecessarilyWritableArray
and Array
should be DefinitelyBothReadableAndWritableArray
. Perhaps not.)
That's a generic user-defined type guard that takes a haystack
array of some type Array<T>
, and a needle
of some type U
which is wider than T
, and if it returns true
, the compiler understands that needle is T
. (It also thinks that false
implies that needle
is not T
, which might not always be desirable. In your case, since T
and U
will both be string literals or unions of them, it's not an issue.)
If we have an implementation for arrayContains()
, we'd change the test to be this:
if (arrayContains(editableFields, field)) {
myObj[field] = newValue; // no error now
}
So, let's implement arrayContains()
.
Here's the next thing: TypeScript's standard library's typings for indexOf()
is this:
interface ReadonlyArray<T> {
indexOf(searchElement: T, fromIndex?: number): number;
}
That means the needle
needs to be of the same type (T
) as the elements of the haystack
. But you want your needle
to be of a wider type (you're looking to see if a string
is in an array of "this" | "that"
elements). So TypeScript will not let you just do this:
function arrayContains<T extends U, U>(haystack: ReadonlyArray<T>, needle: U): needle is T {
return haystack.indexOf(needle) >= 0; // error!
// ~~~~~~ <-- U is not assignable to T
}
Luckily you can safely widen a ReadonlyArray<T>
to a ReadonlyArray<U>
, so you can write it like this:
function arrayContains<T extends U, U>(haystack: ReadonlyArray<T>, needle: U): needle is T {
const widenedHaystack: ReadonlyArray<U> = haystack;
return widenedHaystack.indexOf(needle) >= 0;
}
And that works! You could also, instead, just use a type assertion to silence the compiler, as in:
function arrayContains<T extends U, U>(haystack: ReadonlyArray<T>, needle: U): needle is T {
return haystack.indexOf(needle as T) >= 0; // assert
}
but I usually try to avoid assertions unless they are necessary, and here they are not. It's up to you.
So let's put it all together:
function arrayContains<T extends U, U>(haystack: ReadonlyArray<T>, needle: U): needle is T {
const widenedHaystack: ReadonlyArray<U> = haystack;
return widenedHaystack.indexOf(needle) >= 0;
}
function otherFunction(myObj: TSomeObject, field: keyof TSomeObject, newValue: string): TSomeObject {
const editableFields = ['title', 'description'] as const;
if (arrayContains(editableFields, field)) {
myObj[field] = newValue
}
return myObj
}
Okay, hope that helps. Good luck!
Upvotes: 2