Typed Generic Key Value Interface in Typescript

Benjamin M picture Benjamin M · Mar 29, 2018 · Viewed 15.1k times · Source

I have the following example Object:

let foo: Foo = {
  'key1': { default: 'foo', fn: (val:string) => val },
  'key2': { default: 42, fn: (val:number) => val },

  // this should throw an error, because type of default and fn don't match
  'key3': { default: true, fn: (val:string) => val }
}

The Interface should look something like this:

interface Foo {
  [key: string]: { default: T, fn: (val:T) => any }
}

This of course doesn't work, because there's no T defined.

So I thought about doing this:

interface FooValue<T> {
  default: T;
  fn: (val:T) => any;
}

interface Foo {
  [key: string]: FooValue<?>
}

But there I got stuck, too. Because I can't define the generic type of FooValue.

If I use FooValue<any> then of course everything is typed as any. Though that doesn't work.

I want to ensure that the type of default and the parameter type of fn are always the same.

Is there any solution? Or can't this be done?

Answer

jcalz picture jcalz · Mar 29, 2018

How about defining Foo<T> to be a mapped type, like this:

interface FooValue<T> {
  default: T;
  fn: (val: T) => any;
}

type Foo<T> = {
  [K in keyof T]: FooValue<T[K]>
}

In this case, if T is some normal object type like {a: string, b: number, c: boolean}, then Foo<T> is the Foo-ized version of it: {a: FooValue<string>, b: FooValue<number>, c: FooValue<boolean>}. Now you can make a helper function which accepts an object literal only if it can be inferred as a Foo<T> for some type T:

function asFoo<T>(foo: Foo<T>): Foo<T> {
  return foo;
}

This function works because the TypeScript compiler can do inference from mapped types, allowing it to infer T from Foo<T>. Here is it working:

let foo = asFoo({
  key1: { default: 'foo', fn: (val: string) => val },
  key2: { default: 42, fn: (val: number) => val }
});
// inferred as { key1: FooValue<string>; key2: FooValue<number>;}

And here is it failing:

let badFoo = asFoo(
  key1: { default: 'foo', fn: (val: string) => val },
  key2: { default: 42, fn: (val: number) => val },
  key3: { default: true, fn: (val: string) => val }
}); 
// error! Types of property 'key3' are incompatible. 
// Type 'boolean' is not assignable to type 'string'.

Hope that helps. Good luck!


Update: The above code assumes you're okay with foo.key1.fn('abc') being inferred as type any, since FooValue<string>['fn'] is defined as a function that returns any. It kind of forgets the output type from the original object literal. If you want foo to remember the return type of its properties' fn methods, you can do this slightly different helper function:

function asFoo<T, F>(foo: F & Foo<T>): F {
  return foo;
}

let foo = asFoo({
  key1: { default: 'foo', fn: (val: string) => val },
  key2: { default: 42, fn: (val: number) => val },
  // next line would cause error
  // key3: { default: true, fn: (val: string)=>val} 
})

const key1fnOut = foo.key1.fn('s') // known to be string
const key2fnOut = foo.key2.fn(123) // known to be number

And that works. In this case, asFoo() just verifies that the input is a Foo<T> for some T, but it doesn't coerce the output type to a Foo<T>. Depending on your use cases, you may prefer this solution to the other one. Good luck again.