Rene
Rene

Reputation: 69

Typescript generics strange behavior

do you know why the Typescript compiler (4.3.5) works with the mergeAll1 method but fails on mergeAll2?

The not expected result type Map2<T> & Map2<Base> is not very handy :(

export type ActionReducerX<T> = () => T;

export type Map1<T> = {
  [key in keyof T]: T[key];
};

export type Map2<T> = {
  [key in keyof T]: ActionReducerX<T[key]>;
};

interface Base {
  a: { aa: string };
}

type WithBase<T> = T & Base;

export function mergeAll1<T>(t: Map1<T>): Map1<WithBase<T>> {
  const b: Map1<Base> = {
    a: { aa: '' }
  };

  return {
    ...t,
    ...b
  };
}

export function mergeAll2<T>(t: Map2<T>): Map2<WithBase<T>> {
  const b: Map2<Base> = {
    a: () => ({ aa: '' })
  };

  // error TS2322: Type 'Map2<T> & Map2<Base>' is not assignable to type 'Map2<WithBase<T>>'.
  return {
    ...t,
    ...b
  };
}

Upvotes: 1

Views: 81

Answers (1)

The main issue is in export type ActionReducerX<T> = () => T;.

Seem to be that Map2<WithBase<T>> and Map2<T> & Map2<Base> are assignable to each other:

declare var returnType: Map2<WithBase<Value>>
declare var returnValue: Map2<Value> & Map2<Base>

returnType = returnValue
returnValue = returnType

But this is not the case when we use them with function argument type.

export function mergeAll2<T>(t: Map2<T>): Map2<WithBase<T>> {
    const b: Map2<Base> = {
        a: () => ({ aa: '' })
    };

    let x: Map2<T> & Map2<Base> = null as any as Map2<T> & Map2<Base>;
    let y: Map2<WithBase<T>> = null as any as Map2<WithBase<T>>

    x = y // ok
    y = x // error


    const result = {
        ...t,
        ...b
    };

    return result // error
}

Because function arguments are contravariant, we are no more allowed to assign x to y (Map2<T> & Map2<Base> to Map2<WithBase<T>>), The arrow of inheritance points in the opposite direction .

There is a workaround:

export type ActionReducerX<T> = <U extends T>() => U;

export type Map2<T> = {
    [key in keyof T]: ActionReducerX<T[key]>;
};

interface Base {
    a: { aa: string };
}

type WithBase<T> = T & Base;

export function mergeAll2<T>(t: Map2<T>): Map2<WithBase<T>> {
    // remove explicit type
    const b = {
        a: () => ({ aa: '' })
    };

    return {
        ...t,
        ...b
    }
}

We can make this function contravariant from the beginning.

export type ActionReducerX<T> = <U extends T>() => U;

type Animal = {
    name: string
}

type Dog = {
    paws: 4
} & Animal

type Check = Animal extends Dog ? true : false // false

type Check2 = ActionReducerX<Animal> extends ActionReducerX<Dog> ? true : false // true

Try to remove extra U generic argument, and you will see that inheritance behavior will be changed in opposite direction.

I'm open to any sort of critics since this topic (variance) is still hard for me.

Here, here and here you can find more about this topic

UPDATE Custom wrapper for ActionReducer

Sadly it is not possible for me to extend the ActionReducer

You can just create your own custom type wrapper. See Wrapper

export type ActionReducerX<T> = () => T;

type Wrapper<T extends (...args: any) => any> = <U extends ReturnType<T>>() => U

export type Map2<T> = {
    [key in keyof T]: Wrapper<ActionReducerX<T[key]>>;
};

interface Base {
    a: { aa: string };
}

type WithBase<T> = T & Base;

export function mergeAll2<T>(t: Map2<T>): Map2<WithBase<T>> {
    // remove explicit type
    const b = {
        a: () => ({ aa: '' })
    };

    return {
        ...t,
        ...b
    }
}

type Animal = {
    name: string
}

type Dog = {
    paws: 4
} & Animal

type Check = Animal extends Dog ? true : false // false

type Check2 = Wrapper<ActionReducerX<Animal>> extends Wrapper<ActionReducerX<Dog>> ? true : false // true

Playground

Upvotes: 1

Related Questions