how to explain to typescript that variable is always a string in generic class

Viewed 185

I have the following class and interface definition:

import { Component } from 'react';

type KeysMatching<T, V> = {
    [K in keyof T]-?: T[K] extends V ? K : never
}[keyof T];

interface SearchListProps<T extends object, K extends KeysMatching<T, string>> {
    data: T[];
    sort_and_filter_key: K;
}

class SearchList<ItemDataT extends object, K extends KeysMatching<ItemDataT, string>> extends Component<SearchListProps<ItemDataT, K>> {

    constructor(props: SearchListProps<ItemDataT, K>) {
        super(props);

        const value = props.data[0][props.sort_and_filter_key]; // ItemDataT[K] -> should be string
    }
}

// exmple case: 

interface DataExample {
    name: string, 
    city: string,
    id: number,
    content: object
    // etc
}

const example_data: DataExample[] = [
    {
        name: "asd",
        city: "bcn",
        id: 1,
        content: {}
    },
    {
        name: "dkg",
        city: "nld",
        id: 2,
        content: {}
    }
];

<SearchList data={example_data} sort_and_filter_key={"city"}></SearchList>

As you can see commented in the code typescript thinks that value is of type ItemDataT[K] but it should be string.

Why? because the property sort_and_filter_key should only allow keys of which the value is a string due to the following generic expression: K extends KeysMatching<T, string>. But typescript doesn't seem to understand this.

The autocomplete does seem to understand see image below:
enter image description here
Since this picture clear only allows the two keys that are guaranteed to be strings.

How do i make tsc understand this also?
If anything is unclear please let me know so i can modify the question.

2 Answers

It seems the issue is a limitation of Typescript inference capabilities with respect to generic types - Typescript does not keep track all inferable constraints. Definitely there are some tradeoffs in doing so.

For a concrete type the inference works as expected:

const props: SearchListProps<DataExample> = {
  data: example_data,
  sort_and_filter_key: 'city', 
};

const ok_string = props.data[0][props.sort_and_filter_key];

In case of a generic type we need to manually give a hint to TS engine what exactly we are interested in:

interface SearchListProps<T> {
  data: (T &  Record<KeysMatching<T, string>, string>)[];
  sort_and_filter_key: KeysMatching<T, string>;
}

Interestingly we can intersect T with anything else - for example Record<KeysMatching<T, string>, number>, but then we are not able to initialise SearchListProps with a concrete type.

A fixed and simplified version: https://stackblitz.com/edit/react-ts-x4jdez

Btw. props.data[0][props.sort_and_filter_key] is unsafe (noUncheckedIndexedAccess].

We could also give ItemDataT the constraint Record<K, string> where K is just constrained as string.

class SearchList<
  ItemDataT extends Record<K, string>, 
  K extends string
> extends Component<SearchListProps<ItemDataT, K>> {
    constructor(props: {
        data: ItemDataT[],
        sort_and_filter_key: K
    }) {
        /* ... */
    }
}

When we now access props.data[0][props.sort_and_filter_key], TypeScript still displays the type as ItemDataT[K]. But TypeScript actually knows that this must be a string this time. All the string methods are available. The type is only displayed "wrong" but behaves correctly.

const value = props.data[0][props.sort_and_filter_key]; 

value.charAt(0)
// this was compile time error before

Playground

Related