Search code examples
typescriptgenericstypescript-genericsgeneric-type-parameters

Restrict method on generic class based on generic type parameter in TypeScript


In given function, last function should give a compilation error as it is not allowed.

interface IQuery<T> {
    first<U = T & object>(): Promise<U>
    sum(): Promise<T>;
    map<TR>(fx: (x: T) => TR): IQuery<TR>;
}

async function fx(q: IQuery<Product>) {
    // type is correct
    const total = await q.map((x) => x.price).sum();

    // type is correct
    const firstProduct = await q.map((x) => x).first();

    // type is never, this doesn't give compilation error
    const firstID = await q.map((x) => x.id).first();

}

class Product {
    id?: number;
    price?: number;
}

Last line in function fx compiles successfully however the return type is never but it doesn't give me compilation error.

Basically, method first cannot be called unless map returns an object, not a literal.

How should I restrict first<U = T & object>(): Promise<U>?

I have tried

  1. first<U = T extends object ? T : never>(): Promise<U>
  2. first<U = T>(): U extends object ? Promise<U> : never;

Here is playground link, TypeScript Playground Code

Updated code by Alexander works but doesn't allow inheriting from IQuery again to add few more methods. TypeScript Playground Code


Solution

  • Basically you should make first() not valid if T doesn't extend object, the error message is though kind of cryptic:

    Playground

    interface IQuery<T> {
        first(this: T extends object ? IQuery<T> : never): Promise<T>
        sum(): Promise<T>;
        map<TR>(fx: (x: T) => TR): IQuery<TR>;
    }
    
    async function fx(q: IQuery<Product>) {
        // type is correct
        const total = await q.map((x) => x.price).sum();
    
        // type is correct
        const firstProduct = await q.map((x) => x).first();
    
        // The 'this' context of type 'IQuery<number | undefined>' is not assignable to method's 'this' of type 'never'.(2684)
        const firstID = await q.map((x) => x.id).first();
    
    
    }
    
    class Product {
        id?: number;
        price?: number;
    }
    

    With a type it looks better:

    Playground

    type IQuery<T> = {
        sum(): Promise<T>;
        map<TR>(fx: (x: T) => TR): IQuery<TR>;
    } & (T extends object ? {first(): Promise<T>} : {})
    
    
    async function fx(q: IQuery<Product>) {
        // type is correct
        const total = await q.map((x) => x.price).sum();
    
        // type is correct
        const firstProduct = await q.map((x) => x).first();
    
        // Property 'first' does not exist on type '{ sum(): Promise<number | undefined>; map<TR>(fx: (x: number | undefined) => TR): IQuery<TR>; }'
        const firstID = await q.map((x) => x.id).first();
    
    
    }
    
    class Product {
        id?: number;
        price?: number;
    }