Marek
Marek

Reputation: 2678

TypeScript does not properly check object properties

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

Answers (1)

jcalz
jcalz

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!

Link to code

Upvotes: 2

Related Questions