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)
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.
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.
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> .
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.
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!
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:
{ type: 'xxx' }
themselves, or the individual variable names. So convenient!I'm revisiting this answer occasionally to improve as I learn and expand the system and safeify:
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;
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With