Search code examples
typescriptgenerics

Typescript - specify generic types of another generic


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
    }
}

Usage


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>>


Solution

  • 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.

    Playground link to code