I'd like to write a TypeScript interface is generic over a type function instead of just a type. In other words, I want to write something like
interface Foo<Functor> {
bar: Functor<string>;
baz: Functor<number>;
}
where Functor
is some generic type alias I can pass in from the outside. Then I'll be able to make different kinds of Foo
like this:
type Identity<T> = T;
type Maybe<T> = T | undefined;
type List<T> = T[];
// All of the following would typecheck
const fooIdentity: Foo<Identity> = { bar: "abc", baz: 42 };
const fooMaybe: Foo<Maybe> = { bar: undefined, baz: undefined };
const fooList: Foo<List> = { bar: ["abc", "def"], baz: [42, 43] };
I've tried to find a way to make the compiler accept this but no luck, so I'm wondering if there's a trick I'm missing or if TypeScript just can't express this.
I am late to the party but there is now a comprehensive solution to this problem on the user end, so I thought I might share it.
Details of how it works can be found here, but in a nutshell we exploit the fact that interfaces can receive arguments through intersection:
type Type = { type: unknown, 0: unknown }
interface $Maybe extends Type { type: this[0] | undefined }
type apply<$T extends Type, V> = ($T & [V])['type']
type Maybe3 = apply<$Maybe, 3> // 3 | undefined
Notice how we were able to detach the type constructor from its value.
This pattern is very flexible and enabled the creation of the library free-types which adds a bunch of features, like support for type constraints, partial application, composition, variadicity, optionality, inference, etc.
So what can we do with your use case?
Technically there are ready-made types for List
and Identity
, so we only need to define $Maybe
. You can also set Identity
as a default parameter to Foo
, which is pretty convenient.
import { Type, apply, free } from 'free-types';
interface Foo<$T extends Type<1> = free.Id> {
bar: apply<$T, [string]>;
baz: apply<$T, [number]>;
}
interface $Maybe extends Type<1> { type: this[0] | undefined }
const fooIdentity: Foo = { bar: "abc", baz: 42 };
const fooMaybe: Foo<$Maybe> = { bar: undefined, baz: undefined };
const fooList: Foo<free.Array> = { bar: ["abc", "def"], baz: [42, 43] };
You may want to check that your type constructors can actually accept string | number
.
import { Type, apply, free, Contra } from 'free-types';
interface Foo<$T extends Type<1> & Contra<$T, Type<[string | number]>> = free.Id> {
// ------------------- hacked contravariance
bar: apply<$T, [string]>;
baz: apply<$T, [number]>;
}
const fooIdentity: Foo = { bar: "abc", baz: 42 };
const fooMaybe: Foo<$Maybe> = { bar: undefined, baz: undefined };
const fooList: Foo<free.Array> = { bar: ["abc", "def"], baz: [42, 43] };
// @ts-expect-error: WeakSet expects object, not string | number
type FooWeakSet = Foo<free.WeakSet>;
You may also want to check that your type is actually a Functor (which is only the case for List
here).
import { Type, apply, free, Contra } from 'free-types';
interface Foo<$T extends $Functor> {
bar: apply<$T, [string]>;
baz: apply<$T, [number]>;
}
type $Functor = Type<1, {
map: (f: (...args: any[]) => unknown) => unknown
}>
// @ts-expect-error: Identity lacks a map method
const fooIdentity: Foo<free.Id> = { bar: "abc", baz: 42 };
// @ts-expect-error: our Maybe lacks a map method
const fooMaybe: Foo<$Maybe> = { bar: undefined, baz: undefined };
const fooList: Foo<free.Array> = { bar: ["abc", "def"], baz: [42, 43] };
Finally, If you don't like having 2 versions of the same type, you can conflate them into one.
import { Type, apply, free, $Alter } from 'free-types';
interface Foo<$T extends Type<1>> {
bar: apply<$T, [string]>;
baz: apply<$T, [number]>;
}
type Array<T = never> = $Alter<free.Array, [T]>
type Array1 = Array<1> // 1[]
const fooList: Foo<Array> = { bar: ["abc", "def"], baz: [42, 43] };