4

I spent quite some time on this and I would appreciate some help. I want a component (a function) to accept an array of objects while also validating the properties of the objects.

Interfaces and data:

interface ObjectWithId {
  id: string | number;
}

interface TableMeta<T extends ObjectWithId, K extends PropertyKey = keyof T> {
  data: T[];
  searchKey: K;
  onClick?: (item: T) => void;
}

interface Vegetable {
  id: number,
  label: string,
}

interface Fruit {
  id: number, 
  name: string,
}

const vegetableMeta: TableMeta<Vegetable> = {
  data: [],
  searchKey: 'label', // this only allows 'label' or 'id' 
}

const fruitMeta: TableMeta<Fruit> = {
  data: [],
  searchKey: 'name', // this only allows 'name' or 'id' 
  onClick: (item) => {item.id} // ✔️ has correct item type <---------------
}

const metas = [vegetableMeta, fruitMeta];

A component (a function for simplicity):

const metaParser = (metas: TableMeta<{id: number | string}, PropertyKey>[]) => {
  const id = metas[0].data[0].id; // should be `number | string`
}

metaParser(metas); // ❌ Type 'ObjectWithId' is not assignable to type 'Vegetable'

The shape of objects in the array is unknown in advance

Any ideas on how to make it work?

TS Playground

2 Answers2

1

One way to make it work is to expose the type of the callback and make it more generic when dealing with a collection of objects.

interface ObjectWithId {
  id: string | number;
}

interface TableMeta<
  T extends ObjectWithId,
  K extends PropertyKey = keyof T,
  F extends (item: never) => void = (item: T) => void, // <--- expose the type
> {
  data: T[];
  searchKey: K;
  onClick?: F;
}

interface Vegetable {
  id: number,
  label: string,
}

interface Fruit {
  id: number, 
  name: string,
}

const vegetableMeta: TableMeta<Vegetable> = {
  data: [],
  searchKey: 'label',
}

const fruitMeta: TableMeta<Fruit> = {
  data: [],
  searchKey: 'name',
  onClick: (item) => {item.id}
}

const metas = [vegetableMeta, fruitMeta];

                                                make it more generic ---⌄
const metaParser = (metas: TableMeta<ObjectWithId, PropertyKey, (item: never) => void>[]) => {
  const id = metas[0].data[0].id;
}

metaParser(metas);
0

THIRD UPDATE

type Base = { id: number | string } & { [p: string]: unknown }

interface Data<T extends Base> {
  data: Array<T>;
}

interface SearchKey<T extends Base> {
  searchKey: Exclude<keyof T, 'id'>;
}

type TableMeta<T extends Base> = Data<T> & SearchKey<T>

/**
 * AFAIK, this is the required overhed to be able correctly infer the types
 */
const builder = <T extends Base, K extends Exclude<keyof T, 'id'>>(searchKey: K, data: Array<T>): TableMeta<T> => ({ data, searchKey });


const metas = [
  builder('name', [{ id: 2, name: 2 }]),
  builder('key', [{ id: '2', key: 'John Doe' }]),
  // builder('hello', [{ id: 3, age: 42 }]) //expected error
];

const metaParser = <T extends TableMeta<Base>>(metas: Array<T>) => {
  const result = metas // T[]
  const result2 = metas[0].searchKey// string | number
  const result3 = metas[0].data[0].id// string | number
  return metas
}


const result = metaParser(metas); // ok

Here you can find more info about typing callbacks. I'm not sure if it possible to implement this without helper builder function.

You can consider this answer, but still you have to use extra function, even two

  • Thanks a lot for the response! As I mentioned in the question the shape of objects is unknown in advance. It could be a Fruit and a Vegetable, but it could be something completely different. The function is a reusable component. – Alex Shleifman Jan 13 '21 at 10:12
  • 1
    Your solution works @captain-yossarian, thanks! Maybe I will have to use a builder, but I would prefer not to. I have a solution that works with my original code, but once I added a callback function that depends on type `T` it broke again. I will try to make it work. I've added a link to TS Playground. – Alex Shleifman Jan 14 '21 at 11:00