7

Apologies, I'm sure this has been answered somewhere, but I'm not sure what to google. Please do edit my question if the terms in the title are wrong.

I have something like this:

type RowData = Record<string, unknown> & {id: string}; 


type Column<T extends RowData, K extends keyof T> = {  
  key: K; 
  action: (value: T[K], rowData: T) => void;
}

type RowsAndColumns<T extends RowData> = {
  rows: Array<T>; 
  columns: Array<Column<T, keyof T>>; 
}

And TypeScript should be able to infer the types of the action functions, by examining the shape of the rows, and what key value has been given to the column:

ie:

function myFn<T extends RowData>(config: RowsAndColumns<T>) {

}

myFn({
  rows: [
    {id: "foo", 
      bar: "bar", 
      bing: "bing", 
      bong: {
        a: 99, 
        b: "aaa"
      }
    }
  ], 
  columns: [
    {
      key: "bar", 
      action: (value, rowData) => {
          console.log(value);
      }
    },
     {
      key: "bong", 
      action: (value, rowData) => {
          console.log(value.a); //Property 'a' does not exist on type 'string | { a: number; b: string; }'.

      }
    }
  ]
}); 

Playground Link

The problem is, TypeScript seems to be deriving the type of value (value: T[K]) as 'the types of all accessible by all keys of T' rather than using just the key provided in the column object.

Why is TypeScript doing this, and how can I solve it?

What would make some good answer is defining some specific terms and concepts.

I imagine I want to change my K extends keyof T to be something like 'K is a keyof T, but only one key, and it never changes'.

dwjohnston
  • 11,163
  • 32
  • 99
  • 194

1 Answers1

10

If you expect the keys of T to be a union of literals like "bong" | "bing" | ... (and not just string), then you can express a type which is itself the union of Column<T, K> for each key K in keyof T.

I usually do this via immediately indexing (lookup) into a mapped type:

type SomeColumn<T extends RowData> = {
  [K in keyof T]-?: Column<T, K>
}[keyof T]

but you can also do it via distributive conditional types:

type SomeColumn<T extends RowData> = keyof T extends infer K ?
  K extends keyof T ? Column<T, K> : never : never;

Either way, your RowsAndColumns type would then use SomeColumn instead of Column:

type RowsAndColumns<T extends RowData> = {
  rows: Array<T>;
  columns: Array<SomeColumn<T>>;
}

And this makes your desired use case work as expected without compile errors:

myFn({
  rows: [
    {
      id: "foo",
      bar: "bar",
      bing: "bing",
      bong: {
        a: 99,
        b: "aaa"
      }
    }
  ],
  columns: [
    {
      key: "bar",
      action: (value, rowData) => {
        console.log(value);
      }
    },
    {
      key: "bong",
      action: (value, rowData) => {
        console.log(value.a);
      }
    },
  ]
});

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • Thank you so much! What is that `-?` syntax - I have never seen that. – dwjohnston Nov 09 '20 at 22:15
  • It removes the optional modifier from properties on the mapped type (otherwise you get weird `undefined`s in the value types); see [this documentation](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-8.html#improved-control-over-mapped-type-modifiers) – jcalz Nov 10 '20 at 16:33
  • 1
    Your answers are amazing, thank you so much! :3 – Daniel Rodríguez Meza Mar 09 '22 at 17:54
  • commenting to say this does not work as of TS 4.8.4 – Abe Caymo Oct 21 '22 at 07:22
  • @AbeCaymo I just looked and I don't see any obvious problem; could you elaborate on what, specifically, "does not work"? – jcalz Oct 21 '22 at 12:46