Alex Shleifman
Alex Shleifman

Reputation: 91

Typing and validating an array of objects of unknown shape

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

Upvotes: 4

Views: 7954

Answers (2)

Alex Shleifman
Alex Shleifman

Reputation: 91

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);

Upvotes: 1

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

Upvotes: 0

Related Questions