Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Typescript - Function with bound generic type 'not assignable' to extended type

Tags:

I have a base type Base and I want to create an object that holds functions which receive some subtype of Base (ExtendsBaseA or ExtendsBaseB in this case) and map it to another type C.

I've tried to declare "some subtype of Base" as <T extends Base>, but the type check fails with the following:

Type '(baseA: ExtendsBaseA) => C' is not assignable to type '(base: T) => C'.

Types of parameters 'base' and 'baseA' are incompatible.

Type 'T' is not assignable to type 'ExtendsBaseA'.

Type 'Base' is not assignable to type 'ExtendsBaseA'.

Property 'b' is missing in type 'Base'.

Snippet

interface Base {
    a: string
}

interface ExtendsBaseA extends Base {
    b: string
}

interface ExtendsBaseB extends Base {
    c: string
}

interface C {}


class Foo {
    private readonly handlers: {
        [key: string]: <T extends Base> (base: T) => C
    }

    constructor() {
        this.handlers = {
            'bar' : this.handler
        }
    }

    handler(baseA: ExtendsBaseA): C {
        return <C>null;
    }
}

Try it here

Any ideas on how to solve this?

Edit: Strangely enough if I change:

[key: string]: <T extends Base> (base: T) => C

to:

[key: string]: (base: Base) => C

It works on the playground but not when I try it on my local Typescript installation. (both are 2.9.1)

like image 880
ᴘᴀɴᴀʏɪᴏᴛɪs Avatar asked Jun 11 '18 14:06

ᴘᴀɴᴀʏɪᴏᴛɪs


People also ask

How do you pass a generic class as parameter in TypeScript?

Assigning Generic ParametersBy passing in the type with the <number> code, you are explicitly letting TypeScript know that you want the generic type parameter T of the identity function to be of type number . This will enforce the number type as the argument and the return value.

How do I use generic types in TypeScript?

TypeScript generic type They can be used to identify that specific called function as a type. Making the function itself unaware of which type it's working with. To identify a generic type, you must prefix the function with <Type> where Type is the generic variable. Note: We often use T for generic types.

What is the correct way to define a generic class in TypeScript?

TypeScript supports generic classes. The generic type parameter is specified in angle brackets after the name of the class. A generic class can have generic fields (member variables) or methods. In the above example, we created a generic class named KeyValuePair with a type variable in the angle brackets <T, U> .

What is type guard in TypeScript?

A type guard is a TypeScript technique used to get information about the type of a variable, usually within a conditional block. Type guards are regular functions that return a boolean, taking a type and telling TypeScript if it can be narrowed down to something more specific.


2 Answers

Since you plan for handlers to be a map whose key corresponds to the discriminant of a discriminated union, you should be able to represent that type exactly. Let me flesh out the types you have to include the discriminant:

interface Base {
  type: string
  a: string
}

interface ExtendsBaseA extends Base {
  type: "ExtendsBaseA"
  b: string
}

interface ExtendsBaseB extends Base {
  type: "ExtendsBaseB"
  c: string
}

interface C { }

type BaseUnion = ExtendsBaseA | ExtendsBaseB;

Note that you need to explicitly declare the union, as in BaseUnion above. Now we can define the type HandlerMap as follows.

type HandlerMap = { 
  [K in BaseUnion['type']]?: (base: Extract<BaseUnion, { type: K }>) => C 
}

If you inspect this, it looks like:

type HandlerMap = {
  ExtendsBaseA?: (base: ExtendsBaseA) => C,
  ExtendsBaseB?: (base: ExtendsBaseB) => C
}

Now you can define your Foo class like this:

class Foo {
  private readonly handlers: HandlerMap;

  constructor() {
    this.handlers = {
      ExtendsBaseA: this.handler // changed the key
    }
  }

  handler(baseA: ExtendsBaseA): C {
    return <C>null!;
  }

}

And that all works, as far as it goes. Still, you'll find it frustrating to write a type safe function which takes a HandlerMap and a BaseUnion and tries to produce a C:

function handle<B extends BaseUnion>(h: HandlerMap, b: B): C | undefined {
  const handler = h[b.type] 
  if (!handler) return;
  return handler(b); // error, no valid call signature
}

The TypeScript compiler's control flow analysis isn't sophisticated enough to understand that the argument to h[b.type] will always correspond exactly to the type of b. Instead, it sees that h[b.type] accepts some constituent of BaseUnion, and that b is some constituent of BaseUnion, and balks at the possibility that they don't match up. You can assert that they do match up, which is probably the best you can do:

function handle<B extends BaseUnion>(h: HandlerMap, b: B): C | undefined {
  const handler = h[b.type] as ((b: B) => C) | undefined; 
  if (!handler) return;
  return handler(b); // okay
}

Hope that's of some help. Good luck!

like image 67
jcalz Avatar answered Oct 11 '22 11:10

jcalz


I wasn't happy with the suggested requirement for having a union class that knows about all of the extending class/interfaces, so I looked a little further into a way to work around the issue.

It's worth noting that the error is relatively legitimate - the system can't know that what we're doing is always valid, so it tells us so. So we basically have to work around the type safety - as jcalz does with the as ((b: B) => C)

I approached it from the other direction - tell the compiler that internally I know what I'm doing with the type safety on registration, instead of on callback, but I do still want to correctly type the handlers to derive from the base class.

So here we go:

Registration/Dispatcher:

interface Base {
    type: string;
}

export type Handler<T extends Base> = (action: T) => void;
type InternalHandler = (action: Base) => void;

export class Dispatcher {
    private handlers: { [key: string]: InternalHandler } = {};

    On<T extends Base>(type: string, extHandler: Handler<T>) {
        const handler = extHandler as InternalHandler;
        this.handlers[type] = handler;
    }

    Dispatch(e: Base) {
        const handler = this.handlers[e.type];
        if(handler) handler(e);
    }
}

the events:

export class ExtendsBaseFn implements Base {
    type: 'Fn' = 'Fn';
    constructor(public cb: () => void) { };
}

export class ExtendsBaseNN implements Base {
    type: 'NN' = 'NN';
    constructor(
        public value1: number,
        public value2: number,
    ) { }
}

the handler:

export class ObjWithHandlers {
    constructor() {
        global.dispatcher.On('Fn', (baseFn: ExtendsBaseFn) => {
            baseFn.cb();
        });
        global.dispatcher.On('NN', (baseNN: ExtendsBaseNN) => {
            console.log(baseNN.value1 * baseNN.value2);
        });
    }
}

the driver:

(global as any).dispatcher = new Dispatcher();
const placeholder = new ObjWithHandlers();

and finally, users:

global.dispatcher.Dispatch(new ExtendsBaseFn(() => console.log('woo')));
global.dispatcher.Dispatch(new ExtendsBaseNN(5, 5));

notes:

  • added to global / placeholder for minimal example purposes
  • I like the event definitions as classes for the nice constructor, ease of use, clarity, and safety. As opposed to an interface + a create function, making everyone write the { type: 'xxx' } themselves, or the individual variable names. So convenient!
  • C in this minimal example is void. the return type of the function is ancillary to the actual typing question here.

I'm revisiting this answer occasionally to improve as I learn and expand the system and safeify:

  • Static member types (v3 - super simplified)
  • typeguard check on child classes (without having parent/child dependencies)

export interface IDerivedClass<T extends Base> {
    type: string;
    new (...args: any[]): T;
}

export class Base {
    // no support for static abstract - don't forget to add this in the derived class!
    // static type: string;

    type: string = (this.constructor as IDerivedClass<this>).type;

    Is<T extends IDerivedClass>(ctor: IDerivedClass<T>): this is T {
        return (this.type === ctor.type);
    }
}

export class ExtendsBaseNN extends Base
{
    static type = 'NN';
    ...
}

// show dispatcher ease of use
dispatcher.On(ExtendsBaseNN.type, (nn: ExtendsBaseNN) => {...})

// show typeguard:
const obj: Base = new ExtendsBaseNN(5,5);
if (obj.Is(ExtendsBaseNN)) return obj.value1 * obj.value2;
like image 45
Sekmu Avatar answered Oct 11 '22 11:10

Sekmu