dols3m
dols3m

Reputation: 1955

TypeScript const assertions: how to use Array.prototype.includes?

I am trying to use an array of elements as union type, something that became easy with const assertions in TS 3.4, so I can do this:

const CAPITAL_LETTERS = ['A', 'B', 'C', ..., 'Z'] as const;
type CapitalLetter = typeof CAPITAL_LETTERS[string];

Now I want to test whether a string is a capital letter, but the following fails with "not assignable to parameter of type":

let str: string;
...
CAPITAL_LETTERS.includes(str);

Is there any better way to fix this rather than casting CAPITAL_LETTERS to unknown and then to Array<string>?

Upvotes: 62

Views: 17296

Answers (7)

jcalz
jcalz

Reputation: 328196

The standard library signature for Array<T>.includes(u) assumes that the value to be checked is of the same or narrower type than the array's elements T. But in your case you are doing the opposite, checking against a value which is of a wider type. In fact, the only time you would say that Array<T>.includes<U>(x: U) is a mistake and must be prohibited is if there is no overlap between T and U (i.e., when T & U is never).

Now, if you're not going to be doing this sort of "opposite" use of includes() very often, and you want zero runtime efects, you should just widen CAPITAL_LETTERS to ReadonlyArray<string> via type assertion:

(CAPITAL_LETTERS as ReadonlyArray<string>).includes(str); // okay

If, on the other hand, you feel seriously enough that this use of includes() should be accepted with no type assertions, and you want it to happen in all of your code, you could merge in a custom declaration:

// global augmentation needed if your code is in a module
// if your code is not in a module, get rid of "declare global":
declare global { 
  interface ReadonlyArray<T> {
    includes<U>(x: U & ((T & U) extends never ? never : unknown)): boolean;
  }
  interface Array<T> {
    includes<U>(x: U & ((T & U) extends never ? never : unknown)): boolean;
  }

}

That will make it so that an array will allow any parameter for .includes() as long as there is some overlap between the array element type and the parameter type. Since string & CapitalLetter is not never, it will allow the call. It will still forbid CAPITAL_LETTERS.includes(123), though.

Upvotes: 65

Azerum
Azerum

Reputation: 371

Adding to @imagio's answer, you can make a generic type guard (thanks to @wprl for simplification)

function isIn<T>(values: readonly T[], x: any): x is T {
    return values.includes(x);
}

And use it with any as const array:

const specialNumbers = [0, 1, 2, 3] as const;

function foo(n: number) {
     if (isIn(specialNumbers, n)) {
         //TypeScript will say that `s` has type `0 | 1 | 2 | 3` here
     }
}

Explanation

Type guards

Functions that return something like x is T are known as type guards. TypeScript docs have a great description of what these are here

Essentially, type guards are functions with return type in form parameterName is Type, where parameterName is name of one of the function parameters. During runtime they actually return a boolean

If the function returns true, TypeScript will assume that the value passed to parameter parameterName has type T. We can use this in true branches of conditional statements. As an example:

function isString(x: unknown): x is string {
    return typeof x === 'string' 
}

function formatValue(value: number | string): string {
    if (isString(value)) {
        // This code will be executed only if `isString(value)`
        // returned `true`. Since `isString` is a type guard with type
        // `x is string`, and we pass `value` to `x`, TypeScript
        // will know that `value` has type `string` in this branch

        // This is why we can use `.toUpperCase()` here - this
        // methods exists on values of type `string`
        return value.toUpperCase()
    }

    // This code is executed only when `isString(value)` returned
    // `false`. Here, TypeScript will not that `value` is NOT 
    // of type `string`. Since `value` parameter has type 
    // `number | string`, but is surely not `string` in this part of
    // code, than it has to have type `number` here

    // This is why we can use `.toFixed()` here

    // Note that this line would give error, since `.toUpperCase()`
    // does not exist on type `number`

    //value.toUpperCase()

    return value.toFixed(2)
}

Note that for type guard x is T, TypeScript always assumes that if the type guard function returns true, x surely has type T. We could write isString() incorrectly:

function isString(x: unknown): x is string {
    return typeof x === 'number' 
}

And TypeScript would not give any error inside of formatValue()

During runtime, we would get an error. If we call formatValue(10), the code would execute like this:

function formatValue(value: number | string): string {
    // isString(10) returns `true`, because `typeof 10 === 'number'`
    if (isString(value)) {
        // We attempt to call (10).toUpperCase(). Since
        // Since object Number(10) has no `toUpperCase` property,
        // (10).toUpperCase is `undefined`. Since we try to call it
        // as a function, we get TypeError from JS, during runtime
        return value.toUpperCase()
    }

    return value.toFixed(2)
}

So it's our responsibility to write type predicates that actually return true only when x has type T

as const and readonly T[]

Imagine that we need to check if value: unknown is one of strings 'a', 'b' or 'c' at runtime, and also let TypeScript infer that value has type 'a' | 'b' | 'c' if that's the case

Runtime check can be done using an array:

const letters = ['a', 'b', 'c']

if (letters.includes(value)) {
   // ...
}

But how do we tell TS that value has type 'a' | 'b' | 'c' inside if?

We could use this function:

function isIn<T>(values: T[], x: any): x is T {
    return values.includes(x)
}

But the inference doesn't work:


// TS things that T=string here, so `isIn()` returns `x is string`
if (isIn(letters, value)) {
   // TS known that `value` has type `string` here, but we
   // need a narrower type - 'a' | 'b' | 'c'
}

The root of the problem is that const letters above has type string[]. If we use as const, TS would say that letters has narrower type - ['a', 'b', 'c']`:

// letters is of type readonly ['a', 'b', 'c'] here
const letters = ['a', 'b', 'c'] as const

See What does the "as const" mean in TypeScript and what is its use case? for details

If we try to pass readonly array to isIn(), we get an error:

if (isIn(letters, value)) {
    //   ^ The type 'readonly ["a", "b", "c"]' is 'readonly'
    //     and cannot be assigned to the mutable type 'unknown[]'
}

In theory, isIn() could mutate values inside, and TS tries to prevent mutation of values with readonly types. We say that the parameter of isIn() has readonly type, declaring intent the it does not modify it's parameter:

function isIn<T>(values: readonly T[], x: any): x is T {
    return values.includes(x);
}

Finally, type inference works:

// Since `letters` has type `['a', 'b', 'c']` here,
// TS infers `T` in `T[]` to be `'a' | 'b' | 'c'`
if (isIn(letters, value)) {
    // So isIn() returns `x is 'a' | 'b' | 'c'`, and
    // TS says that `value` has type `'a' | 'b' | 'c'` here
}

Upvotes: 11

imagio
imagio

Reputation: 1490

Another way to solve it is with a type guard

https://www.typescriptlang.org/docs/handbook/advanced-types.html#user-defined-type-guards

const myConstArray = ["foo", "bar", "baz"] as const

function myFunc(x: string) {
    //Argument of type 'string' is not assignable to parameter of type '"foo" | "bar" | "baz"'.
    if (myConstArray.includes(x)) {
        //Hey, a string could totally be one of those values! What gives, TS?
    }
}

//get the string union type
type TMyConstArrayValue = typeof myConstArray[number]

//Make a type guard
//Here the "x is TMyConstArrayValue" tells TS that if this fn returns true then x is of that type
function isInMyConstArray(x: string): x is TMyConstArrayValue {
    return myConstArray.includes(x as TMyConstArrayValue)

    //Note the cast here, we're doing something TS things is unsafe but being explicit about it
    //I like to this of type guards as saying to TS:
    //"I promise that if this fn returns true then the variable is of the following type"
}

function myFunc2(x: string) {
    if (isInMyConstArray(x)) {
        //x is now "foo" | "bar" | "baz" as originally intended!
    }
}

While you have to introduce another "unnecessary" function this ends up looking clean and working perfectly. In your case you would add

const CAPITAL_LETTERS = ['A', 'B', 'C', ..., 'Z'] as const;
type CapitalLetter = typeof CAPITAL_LETTERS[string];
function isCapitalLetter(x: string): x is CapitalLetter {
    return CAPITAL_LETTERS.includes(x as CapitalLetter)
}

let str: string;
isCapitalLetter(str) //Now you have your comparison

//Not any more verbose than writing .includes inline
if(isCapitalLetter(str)){
  //now str is of type CapitalLetter
}

Upvotes: 17

marko424
marko424

Reputation: 5356

I strongly recommend including ts-reset library into you projects, which will improve TypeScript's built-in typings.

Upvotes: 2

You can also create a curried version of Array.prototype.includes which works with tuples:

const PROPS = ['a', 'b', 'c'] as const;

const withTuple = <
    List extends string[]
>(list: readonly [...List]) =>
    (prop: string): prop is List[number] =>
        list.includes(prop)

const includes = withTuple(PROPS);

const result = includes('d')

declare let str: string

if (includes(str)) {
    str // "a" | "b" | "c"
}

Playground

Higher order function with list argument created for inference.

You can also check my article

Upvotes: 0

user9547708
user9547708

Reputation: 397

using lodash

const CAPITAL_LETTERS = ['A', 'B', 'C', 'Z'] as const;
_.includes(CAPITAL_LETTERS, 'A');

Upvotes: -3

Qtax
Qtax

Reputation: 33908

Here's a solution that works well for strings & string literals using TypeScript 4.1 Template Literal Types that doesn't break anything else, and also narrows the type for convenience when used in conditions:

declare global {
    interface ReadonlyArray<T> {
        includes<S, R extends `${Extract<S, string>}`>(
            this: ReadonlyArray<R>,
            searchElement: S,
            fromIndex?: number
        ): searchElement is R & S;
    }
}

Originally posted by noppa in a TypeScript github issue related to this.

Upvotes: 5

Related Questions