'TypeScript Generics column.id should be within row[key]

I'm writing a TypeScript Interface for Tables:

interface Column {
    id: string;
    label: string;
}

interface Data {
    [key: string]: string;
}

interface Table {
    columns: Column[];
    data: Data[];
}

I'd like to restrict the allowed values for Column.id: Each Column.id must have a matching Data.key. (But: Not every Data.key must have a matching Column.id)

Examples:

This should be allowed, because every Column.id has a matching Data.key.

columns: [
  { id: 'foo', label: 'foo' },
  { id: 'bar', label: 'bar' }
]

data: [
  { foo: '123', bar: '456', baz: '789' }
]

But this should not be allowed, because Data[foo] doesn't exist.

columns: [
  { id: 'foo', label: 'foo' }
]

data: [
  { bar: '456' }
]

How is it possible to write a Table interface, which applies these constraints?



Solution 1:[1]

You can't make a concrete type that represents this constraint, but you can use generics along with a helper function to infer a generic type that enforces that constraint.

Let's extend the definitions of Column, Data, and Table to be generic in the string properties we care about:

interface Column<K extends string = string> {
  id: K,
  label: string;
}

type Data<K extends string = string> = Record<K, string>

interface Table<K extends string = string, L extends K = K> {
  columns: Column<L>[];
  data: Data<K>[];
}

Note how a valid Table<K, L>, assuming K and L are unions of string literals and that K is as narrow as it can be, expresses the constraint you want. Since L extends K, it means that columns['id'] must be a subtype of keyof data.

The following helper function will do the inference for you:

const asTable = <K extends string, L extends K>(x: Table<K, L>) => x;

Okay, let's see if it works:

// no error
const goodTable = asTable({
  columns: [
    { id: 'foo', label: 'foo' },
    { id: 'bar', label: 'bar' }
  ],
  data: [
    { foo: '123', bar: '456', baz: '789' }
  ]    
})

// error ... Type '"foo"' is not assignable to type '"bar"'
const badTable = asTable({
  columns: [
    { id: 'foo', label: 'foo' }
  ]
  ,
  data: [
    { bar: '456' }
  ]
})

Looks good. Hope that helps!

Solution 2:[2]

Here's another solution, which seems to work.

I'm not sure what are the pros and cons compared to @jcalz version.

Maybe someone can leave a comment and explain the differences :)

interface Column<K extends string> {
    id: K;
    label: string;
}

interface Props<D extends {[key: string]: string}> {
    columns: Array<Column<keyof D & string>>;
    data: D[];
}

Solution 3:[3]

Maybe easier base on @jcalz

interface Column<K extends string = string> {
  name: K;
  friendly_name: string;
  type: string;
}

type Data<K extends string = string> = Record<K, any>;

interface UniformedData<K extends string = string> {
  columns: Column<K>[];  
  //rows: Data<ValueOf<Pick<Column<K>, 'name'>>>[];//type ValueOf<T>=T[keyof T];
  rows: Data<Column<K>['name']>[];
}

// Runtime type-inferring must on funcs
export const asTable = <K extends string>(x: UniformedData<K>) => x;

const check = asTable({
  columns: [{ name: 'c1', friendly_name: 'C1', type: 'string' }],
  rows: [{ c2: 'x' }], // error!
});
ยทยทยท

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 jcalz
Solution 2 Benjamin M
Solution 3 Tearf001