Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Typed Generic Key Value Interface in Typescript

Tags:

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?

like image 900
Benjamin M Avatar asked Mar 29 '18 18:03

Benjamin M


People also ask

How do I use generics in interface TypeScript?

TypeScript - Generic Interface The above IProcessor is a generic interface because we used type variable <T> . The IProcessor interface includes the generic field result and the generic method process() that accepts two generic type parameters and returns a generic type. As you learned, you can use interface as type.

What is generic type in TypeScript?

Generics allow creating 'type variables' which can be used to create classes, functions & type aliases that don't need to explicitly define the types that they use. Generics makes it easier to write reusable code.

What is type T in TypeScript?

This article opts to use the term type variables, coinciding with the official Typescript documentation. T stands for Type, and is commonly used as the first type variable name when defining generics. But in reality T can be replaced with any valid name.


1 Answers

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.

like image 188
jcalz Avatar answered Oct 12 '22 05:10

jcalz