Is it possible to somehow specify the generic parameters of a generic type?
I am trying to create a fully typed api url builder, which will set correct types on my data models based on the specified options for the url.
Question was edited (see history for previous versions):
/**
* A base data model class
*/
class BaseModel<T extends Record<string, any>> {
private data: T = {} as T
getAttribute<K extends keyof T>(key: K): T[K] {
return this.data[key]
}
}
/**
* A derived data model class, which includes custom methods
*/
class CustomModel extends BaseModel<{ foo: string, bar: number }> {
someMethod() { }
}
type GetAttributes<T extends BaseModel<any>> = T extends BaseModel<infer A> ? A : never
type PickAndNullifyOthers<T, K extends keyof T> = {
[P in keyof T]: P extends K ? T[P] : null
}
type GetModelFromBuilder<T> = T extends Builder<infer M> ? M : never
/**
* A URL builder class, which allows to pick only specific attributes from a model
*/
class Builder<T extends BaseModel<any>> {
pickOnly<K extends keyof GetAttributes<T>>(key: K): Builder<BaseModel<PickAndNullifyOthers<GetAttributes<T>, K>>> {
return {} as any
}
}
const wrapper = new Builder<CustomModel>()
.pickOnly('foo') // we picked only the `foo` attribute, so others should be `null`
const model = {} as GetModelFromBuilder<typeof wrapper>
const fooResult = model.getAttribute('foo')
const barResult = model.getAttribute('bar')
// the types of `CustomModel` getters are lost because it is typed as `BaseModel`
model.someMethod() // error
The problem I ran into is that when I try to get the new type of the data model with the adjusted attributes, it shows up as BaseModel
and I'd like it to be CustomModel
as in the generic T
: class Builder<T extends BaseModel<any>>
This isn't currently possible as asked. The big stumbling block is that TypeScript doesn't have a direct way to represent higher kinded types as requested in microsoft/TypeScript#1213. First, in order to even get close, you'd need CustomModel
to be generic so that sometimes its bar
property can be null
(right now it is always number
):
class CustomModel<T extends { foo: any, bar: any }> extends BaseModel<T> {
someMethod() { }
get foo() {
return this.getAttribute('foo')
}
get bar() {
return this.getAttribute('bar')
}
}
const m = new CustomModel<{ foo: string, bar: number }>();
m.bar // number
const m2 = new CustomModel<{ foo: string, bar: null }>();
m2.bar // null
And your hope is that Builder
would be generic in some type which is itself generic, sort of like this:
// not valid TS, don't try this:
declare class Builder<B<~> extends BaseModel<~>, T> {
pickOnly<K extends keyof T>(
key: K
): Builder<B, PickAndNullifyOthers<T, K>>;
getModel(): B<T>
}
const wrapper = new Builder<CustomModel, { foo: string, bar: number }>()
.pickOnly('foo')
const model = wrapper.getModel();
const fooResult = model.foo // string
const barResult = model.bar // null
But TypeScript doesn't support this. That's what microsoft/TypeScript#1213 is about.
TypeScript only has generic types (e.g., B
will be resolved to some type like type B = CustomModel<{foo: string, bar: number}>
), not generic type functions (e.g., B
would be resolved to some type function like type B<T> = CustomModel<T>
).
There are various ways to try to encode generic type functions in TypeScript, some of which are mentioned in microsoft/TypeScript#1213, but they usually involve a bunch of boilerplate and/or "registering" ahead of time the things you'd like to treat as higher order generics. In some sense, Builder
would need to know about all possible generic model. For example:
interface ModelRegistry<T> { }
type Apply<B extends keyof ModelRegistry<T>, T> = ModelRegistry<T>[B]
And then when you define CustomModel
you can merge an appropriate entry for it:
class CustomModel<T extends { foo: any, bar: any }> extends BaseModel<T> {⋯}
interface ModelRegistry<T> { CustomModel: CustomModel<Extract<T, { foo: any; bar: any; }>> }
class AnotherModel<T extends { baz: any }> extends BaseModel<T> {⋯}
interface ModelRegistry<T> { AnotherModel: AnotherModel<Extract<T, { baz: any }>> }
And then Builder
can be written in terms of the registered model name and its attribute type:
declare class Builder<B extends keyof ModelRegistry<T>, T extends Record<string, any>> {
pickOnly<K extends keyof T>(
key: K
): Builder<B, PickAndNullifyOthers<T, K>>;
getModel(): Apply<B, T>
}
const wrapper = new Builder<"CustomModel", { foo: string, bar: number }>()
.pickOnly('foo')
const model = wrapper.getModel();
const fooResult = model.foo // string
const barResult = model.bar // null
This sort of looks like what you're asking for, but the boilerplate for registering models might not be what you want. There are other approaches mentioned in microsoft/TypeScript#1213, and some people claim they are less tedious, so possibly some approach like this meets your needs. Without native higher kinded types, though, it's always going to look like a workaround.