p1e7r0
p1e7r0

Reputation: 23

Typescript: correlation between a field and the type of a function's parameter

I am developing a generic component for creating tables given the configuration of the columns.

Each row of the table is represented by a data model:

export interface Item {
  field1: string,
  field2: {
    field21: number,
    field22: {
      field221: string;
      field222: string;
    },
  },
}

The configuration of table columns is done using the ColumnsType type.

type Magic<T> = any; // The type of the value extracted with the path

export interface ColumnType<T> {
  path: Paths<T>, // 'field1' | 'field2' | 'field2.field21' | 'field2.field22' | 'field2.field22.field221' | 'field2.field22.field222' 
  format?: (value: Magic<T>) => string,
}

export type ColumnsType<T> = Array<ColumnType<T>>;

Column configuration:

export const itemsColumns: ColumnsType<Item> = [
  {
    path: 'field1',
  },
  {
    path: 'field2',
    format: ({field21, field22}) => field21 > 0 ? field22.field221 : field22.field221
  },
  {
    path: 'field2.field22',
    // format: ({field221, field222}: Item['field2']['field22']) => `${field221} - ${field222}`,
    format: ({field221, field222}) => `${field221} - ${field222}`,
  },
];

Question

  1. How do I create the type alias Magic? Without defining the type in the configuration eg. Item ['field2'] ['field22']

Thank you very much

playground

Upvotes: 2

Views: 266

Answers (1)

jcalz
jcalz

Reputation: 328362

If you want the path and format properties of ColumnType<T> to be correlated, then you can't do it where ColumnType<T> is a single interface; in your definition:

export interface ColumnType<T> {
  path: Paths<T>, 
  format?: (value: Magic<T>) => string,
}

the Paths<T> type is a union of all possible dotted paths from T, and no matter what Magic<T> is, there's no way for it to depend on pieces of Paths<T>.

What you really want is for ColumnType<T> to itself be a union of path/format pairs, for each path in Paths<T>. This implies that we should forget about separate Paths<T> and Magic<T> types, and try to build them together.

Here's one possible solution:

// Join<K, P> prepends key K to path P with a dot 
//   (unless either K or P are empty)
type Join<K, P> = K extends string | number
  ? P extends string | number
  ? `${K}${'' extends P ? '' : '.'}${P}`
  : never
  : never;

// PathFormat<P, V> is the path/format pair for path P and value V
type PathFormat<P, V> = { path: P, format?: (value: V) => string }

// PrependToPathFormat<K, PF> takes a key K and an existing
//   PathFormat PF and produces a new PathFormat where the key K
//   is prepended to the path
type PrependToPathFormat<K, PF> =
  PF extends PathFormat<infer P, infer V> ? PathFormat<Join<K, P>, V> : never

// ColumnType<T> is the union of PathFormat<K, T[K]> for 
//  every key K in keyof T and, recursively, the result of prepending K 
//  to the PathFormat union from ColumnType<T[K]>
type ColumnType<T> = T extends object ? { [K in keyof T]-?:
  PathFormat<K, T[K]> | (PrependToPathFormat<K, ColumnType<T[K]>>)
}[keyof T] : never

The idea here is that ColumnType<T> will be a union of PathFormat<K, T[K]> for each key K and value T[K] in T, together with a transformed version of ColumnType<T[K]>.


Let's look at an example:

interface SimpleObject { c: number, d: boolean }
type ColumnTypeSimple = ColumnType<SimpleObject>;
// type ColumnTypeSimple = 
//  PathFormat<"c", number> | 
//  PathFormat<"d", boolean>

interface NestedObject { a: string, b: SimpleObject }
type ColumnTypeNested = ColumnType<NestedObject>;
// type ColumnTypeNested = 
//  PathFormat<"a", string> | 
//  PathFormat<"b", SimpleObject> | 
//  PathFormat<"b.c", number> | 
//  PathFormat<"b.d", boolean>

You can see that ColumnType<SimpleObject> is the union of PathFormat types for the c and d properties of SimpleObject. And ColumnType<NestedObject> is the union of PathFormat types for the a and b properties of NestedObject, as well as ColumnType<SimpleObject> transformed so that the paths "c" and "d" become "b.c" and "b.d" respectively.


So now we can test your itemsColums code, which works:

const itemsColumns: ColumnsType<Item> = [
  {
    path: 'field1',
  },
  {
    path: 'field2',
    format: ({ field21, field22 }) => field21 > 0 ? field22.field221 : field22.field221
  },
  {
    path: 'field2.field22',
    format: ({ field221, field222 }) => `${field221} - ${field222}`,
  },
]; // okay

and we will also catch errors where the path or format are incorrect or do not match each other:

const badItemsColumns: ColumnsType<Item> = [
  { path: "field3" }, // error! 
  //~~~~ <--
  //Type '"field3"' is not assignable to type 
  //'"field1" | "field2" | "field2.field21" | "field2.field22" |
  //"field2.field22.field221" | "field2.field22.field222"'.

  { path: "field2.field21", format: (({ field222 }) => "") }, // error!
  // ---------------------------------> ~~~~~~~~
  // Property 'field222' does not exist on type 'Number'.
];

PLEASE NOTE that such mapped recursive types can have weird edge cases, so you should definitely test it out fully to see that it meets your use cases. Depending on the type T passed in to ColumnType<T>, you could get weird output, or even hit recursion limits or performance problems with the compiler. There are ways to try to mitigate these, but addressing all of them in advance is out of the scope of a single Stack Overflow question.

Playground link to code

Upvotes: 1

Related Questions