Base type on object

First off – Sorry for the long explanation, but I am unsure how to make it clear enough what I want without it. But…I am making a Table component in React (with typescript). And I am thinking something similar to this:

const data = [
 { id: 1, name: "Peter", age: 43, country: "USA" },
 { id: 2, name: "Stewie", age: 2, country: "UK" },
 { id: 3, name: "Brian", age: 10, country: "Spain" },
]

<Table
  data={data}
  headers={[
    {
      title: 'Name',
      value: 'name'
    },
    {
      title: 'Age (In Years)',
      value: 'age'
    },
  ]} />

So I wanted to make sure that the selected value is limited to the props of the original data. Which I did with this interface:

interface HeaderData<ItemKey> {
  accessor: ItemKey;
  title: string;
}

interface Props<Item> {
  data: Array<Item>;
  headers: Array<HeaderData<keyof Item>>;
}

Which works brilliantly. So now comes the problem. I wanted to include the ability to add a new row. So for that to happen I am rendering a text box for each column and I want to keep track of the data entered for the columns using a useState. So I could just write the following:

const [data, setData] = useState<Partial<Item>>({});

And then set data as I go. But I really would love to base the data object on the headers so that if the headers are only name and age like in the example. I’d only have a data object of { name: string, age: string }

So essentially the useState default value is:

const [data, setData] = useState(
  headers.reduce((curr, next) => ({ ...curr, [next.value]: "" }), {})
);

When I look at what the typescript interprets this into {} – But when I later try to access it using data[header.value] it complains

Type 'keyof T' cannot be used to index type '{}'. 

and it does not help if I do: data[header.value.toString()] or something like that neither.

What type do I need to set to put a constraint on “data” so that I only get the “header” values?

Thanks for reading 😉

EDIT: Thanks @JonathanHamel for pointing out a glaring mistake. But the type question still remains.

Answer

Here is my understanding of your problem. Your initial data set includes four properties id, name, age, and country. Your table only displays two of those properties: name and age. You want to be able to add rows to the table and require that added object only need the two visible properties name and age instead of all four.

If Item is the whole object then the visible portion is Pick<Item, 'name' | 'age'>. But we want to be able to infer which properties are picked from the headers object. So we will use a generic K extends keyof Item to describe the visible columns. Our data just needs the visible properties Pick<Item, K>.

Edited per your comment: the state of the rows will be stored outside the table, but the state of the row which we are creating by clicking “Add” will be stored inside the table. All of the values of the added row will be in string format due to the limitations of the input component. So the added row is an object with the keys K and the values string which is Record<K, string>.

interface HeaderData<ItemKey> {
    accessor: ItemKey;
    title: string;
}

interface Props<Item, K extends keyof Item> {
    data: Array<Pick<Item, K>>;
    headers: Array<HeaderData<K>>;
    onAddRow: (row: Record<K, string>) => void;
}

const Table = <Item, K extends keyof Item>({ data, headers, onAddRow }: Props<Item, K>) => {
    // create a new row object with empty string properties matching the header accessors
    // need to assert type or else it thinks the keys are just string
    const emptyRow = Object.fromEntries(headers.map(h => [h.accessor, ""])) as Record<K, string>;

    // the contents of the add row section
    const [nextRow, setNextRow] = useState(emptyRow);
    // whether or not add row section is expanded
    const [isAdding, setIsAdding] = useState(false);

    const onClickAdd = () => {
        // enter edit mode
        setIsAdding(true);
    }

    const onClickSave = () => {
        // push changes to parent
        onAddRow(nextRow);
        // exit edit mode
        setIsAdding(false);
        // reset nextRow
        setNextRow(emptyRow);
    }

    return (
        <table>
            <thead>
                <tr>
                    {headers.map(header => (
                        <th scope="col">{header.title}</th>
                    ))}
                </tr>
            </thead>
            <tbody>
                {data.map(row => (
                    <tr>{/**... */}</tr>
                ))}
            </tbody>
        </table>
    )
};

export const Test = () => {

    const [data, setData] = useState(initialData);

    const onAddRow = ({ name, age }: { name: string; age: string }) => {
        setData(data => [...data, {
            name,
            // convert string to number
            age: parseInt(age),
            // fill in missing properties
            country: "USA",
            id: -1,
        }])
    }

    return (
        <Table
            data={data}
            headers={[
                {
                    title: 'Name',
                    accessor: 'name'
                },
                {
                    title: 'Age (In Years)',
                    accessor: 'age'
                },
            ]}
            onAddRow={onAddRow}
        />
    )
}

I’m actually a bit surprised that the above code gets proper inference since our types don’t require that headers is exhaustive (so K could be just keyof Item and not a subset). But it seems to work!

The Table element in Test is getting the types inferred as:

  • Item: { id: number; name: string; age: number; country: string; }
  • K: "name" | "age"

Typescript Playground Link