javascripttypescriptpromise

promise generic assignment


I am not sure why the get function is not strongly typed.

it is defined as get:(id:string) => IPromise<Test>;

but, I am assigning to it as

    constructor(srv:Store<TestData>){
        this.get = (id) => this.getImp(srv, id);
    }
    
    private getImp(srv:Store<TestData>, d:string) {
        return srv.get("/test")
            .then( (d) => { //d == IPromise<getImp>
                var rep = d.reply; //rep == TestData
                //var rep:string = "" gives error
                return rep;
            });
    }

getImp returns a prmise (result of get) with a typed parameter TestData. But this should not be compatible with Test, hence with get. Why there is no error?

there is no error if I change the signature of getImp as

private getImp(srv:Store<TestData>, d:string):IPromise<TestData> {

how getImp(srv:Store<TestData>, d:string):IPromise<TestData> is assignable to get:(id:string) => IPromise<Test>

anything wrong with the definition of IPromise ?

thanks.

full source

export interface IGetResult<T> {
    reply: T
}

export class Store<T>{
    get:(r:any) => IPromise<IGetResult<T>>;
}

 export interface TestData {
    Id:         string
    Name    :   string
}
export interface ITest extends TestData{
    test():void
}

export class Test implements ITest {
    Id:string;
    Name:string;
    test(){}
}
export class service{
    get:(id:string) => IPromise<Test>;
    
    constructor(srv:Store<TestData>){
        this.get = (id) => this.getImp(srv, id);
    }
    
    private getImp(srv:Store<TestData>, d:string):IPromise<TestData> {
        return srv.get("/test")
            .then( (d) => { //d == IPromise<TestData>
                var rep = d.reply; //rep == TestData
                //var rep:string = "" gives error
                return rep;
            });
    }
}


 //==========  angular.d.ts  ===========
 // from https://github.com/borisyankov/DefinitelyTyped/blob/master/angularjs/angular.d.ts
  
  export interface IRequestConfig {
        method: string;
        url: string;
        params?: any;

        // XXX it has it's own structure...  perhaps we should define it in the future
        headers?: any;

        cache?: any;
        withCredentials?: boolean;

        // These accept multiple types, so let's define them as any
        data?: any;
        transformRequest?: any;
        transformResponse?: any;
        timeout?: any; // number | promise
    }

 export   interface IHttpPromiseCallback<T> {
        (data: T, status: number, headers: (headerName: string) => string, config: IRequestConfig): void;
    }


export interface IHttpPromiseCallbackArg<T> {
        data?: T;
        status?: number;
        headers?: (headerName: string) => string;
        config?: IRequestConfig;
    }
    
export interface IHttpPromise<T> extends IPromise<T> {
        success(callback: IHttpPromiseCallback<T>): IHttpPromise<T>;
        error(callback: IHttpPromiseCallback<T>): IHttpPromise<T>;
        then<TResult>(successCallback: (response: IHttpPromiseCallbackArg<T>) => TResult, errorCallback?: (response: IHttpPromiseCallbackArg<T>) => any): IPromise<TResult>;
        then<TResult>(successCallback: (response: IHttpPromiseCallbackArg<T>) => IPromise<TResult>, errorCallback?: (response: IHttpPromiseCallbackArg<T>) => any): IPromise<TResult>;
    }
    
    export interface IPromise<T> {
        then<TResult>(successCallback: (promiseValue: T) => IHttpPromise<TResult>, errorCallback?: (reason: any) => any, notifyCallback?: (state: any) => any): IPromise<TResult>;
        then<TResult>(successCallback: (promiseValue: T) => IPromise<TResult>, errorCallback?: (reason: any) => any, notifyCallback?: (state: any) => any): IPromise<TResult>;
        then<TResult>(successCallback: (promiseValue: T) => TResult, errorCallback?: (reason: any) => TResult, notifyCallback?: (state: any) => any): IPromise<TResult>;


        catch<TResult>(onRejected: (reason: any) => IHttpPromise<TResult>): IPromise<TResult>;
        catch<TResult>(onRejected: (reason: any) => IPromise<TResult>): IPromise<TResult>;
        catch<TResult>(onRejected: (reason: any) => TResult): IPromise<TResult>;

        finally<TResult>(finallyCallback: ()=>any):IPromise<TResult>;
}

Solution

  • Generics in TypeScript are of different design. In some cases they're bivariant WRT their type parameter, and you're expecting that they're covariant in this particular case. Promises are immutable so afaik they should indeed be covariant wrt the type they're holding.

    To see that its so, change

    export interface ITest extends TestData
    

    to

    export interface ITest
    

    then remove the property Name:string; from class Test. If you do this, then typescript will finally complain that it "Cannot convert IPromise<TestData> to IPromise<Test>" because neither Test can be derived from TestData nor TestData can be derived from Test.

    A more direct illustration of this bug:

    var x: IPromise<Test> = <IPromise<TestData>><any>{}
    

    As long as Test inherits from TestData or TestData inherits from Test, this remains valid.


    Explanation of the terms covariant and bivariant

    A generic type is covariant WRT its type parameter if its hierarchy varies together with the hierarchy of the type parameter. Example:

    If

    then

    A generic type is bivariant WRT its type parameter if the subtyping goes both ways:

    If

    then

    Final note: To be type-safe, a mutable Array should actually be invariant WRT to the argument. Many languages such as C# or Java make it covariant - but in this case its possible to push(item:OtherExtendedItem); into an array of ExtendedItem without it being caught as an error at compile time.


    What does "sometimes bivariant" mean though? It means that typescript doesn't actually have any hard-coded variance rules. When it encounters a specialization of a generic type, it just applies that type parameter. After it does that, it performs a structural check of the resulting types - if they match, then everything is okay. Consider the following example:

    class Box<T>{   
        public x: number;
        constructor(t:T) {
            this.x = 1;         
        }
    }
    interface AB  { a: number; b: number; }
    interface ABC { a: number; b: number; c: number; }
    
    var ab:Box<AB> = new Box({a: 1, b: 2});
    
    var y:Box<ABC> = ab;
    

    The compiler considers this code valid. Whether the type has been specialized with T = AB or T = ABC doesn't change the structure of the resulting type, which is simply {x: number}

    What happens if we have a method in Box that returns a Box? Lets add that method and see:

    getThis():Box<T> { return this; }
    

    Again, if we apply the specialization, we get an infinite, recursive structure:

    {x: number, getThis: () => {x : number, getThis: () => ... }}
    

    that ends up being the same regardless of the specialization.

    But, if we add another method instead, one that retuns a value of type T:

    getValue():T { return <T>{}; }
    

    Then if we apply the specialization T = AB, it results with the following structure:

    {x: number; y: () => { a: number; b number; } }
    

    which is incompatible with the specialization T = ABC

    {x: number; y: () => { a: number; b: number; c: number; } }
    

    and the compiler will finally complain.

    At this point, you may notice that IPromise<T> isn't really quite like Box<T>. Indeed, it does not have a method that returns a value of type T, but it has methods (like then and catch) that take callbacks which take values of type T. Lets add a method like that to Box

    then<U>(f: (val: T) => Box<U>): Box<U>;
    

    Now lets apply the type specialization T = AB

    {x: number; then: (val: {a: number; b: number;}) => Box<{x: number; then: ...}}
    

    and T = ABC

    {x: number; then: (val: {a: number; b: number; c: number;}) => Box<{x: number; then: ...}}
    

    These should not be compatible!

    Which leads us to the true problem in TypeScript: arguments of function type ("callbacks") are bivariant WRT their input arguments, when they should be contravariant.

    Here is a simple example that demonstrates the problem:

    interface IA { bar(): void }
    interface IB { bar(): void; foo(): void; }
     
    function fn(passedFn: (a: IA) => void) {
      var a:IA = {bar: function(){}}; 
      passedFn(a); 
    }
     
    // compiles but throws TypeError at runtime!
    fn((b: IB) => b.foo());
    

    In this example, a callback that takes an argument of type IA cannot be safely substituted with a callback that takes an argument of type IB (when IB extends IA). Like in the example, the callback taking the argument of type IB may try to call the method foo(), which means that passing an argument of type IA to that callback will result with a type error


    The quickest fix for your issue would be to find a member to IPromise that returns a value of type T:

    export interface IPromise<T> {
       __bivariance_fix: T;
    }
    

    Of course this will make the interface incompatible with other promise types (or libraries), so the real fix here is for TypeScript to correct behavior of callbacks.