Typescript - Funktion med bundet generiskt typ 'ej tilldelningsbar' till utökad typ


Jag har en basklass Base och jag vill skapa ett objekt som innehåller funktioner som tar emot någon underklass av Base (ExtendsBaseA eller ExtendsBaseB i det här fallet) och mappar den till en annan typ C.

Jag har försökt att deklarera "någon subtyp av Base" som <T extends Base>, men typkontrollen misslyckas med följande:

Typen '(baseA: ExtendsBaseA) => C' är inte tilldelningsbar till typen '(base: T) => C'.

Typerna av parametrarna 'base' och 'baseA' är inkompatibla.

Typ 'T' kan inte tilldelas typen 'ExtendsBaseA'.

Typen 'Base' kan inte tilldelas typen 'ExtendsBaseA'.

Egenskapen 'b' saknas i typen 'Base'.

Utdrag

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;
    }
}

Prova det här

Några idéer på hur man kan lösa detta?

Redigera: Märkligt nog om jag ändrar:

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

till :

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

Det fungerar på lekplatsen men inte när jag försöker det på min lokala Typescript-installation. (båda är 2.9.1)

Svar #1

Eftersom du planerar att handlers ska vara en karta där nyckeln motsvarar diskriminanten hos en diskriminerad union, borde du kunna representera den typen exakt. Låt mig utveckla de typer du har för att inkludera diskriminanten:

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;

Observera att du behöver uttryckligen deklarera unionen, som i BaseUnion ovan. Nu kan vi definiera typen HandlerMap på följande sätt.

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

Om du inspekterar detta, ser det ut så här:

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

Nu kan du definiera din Foo-klass så här:

class Foo {
  private readonly handlers: HandlerMap;

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

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

}

Och allt det fungerar, så långt det går. Ändå kommer du att tycka att det är frustrerande att skriva en typ-säker funktion som tar en HandlerMap och en BaseUnion och försöker producera en 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
}

TypeScript-kompilatorns kontrollflödesanalys är inte tillräckligt sofistikerad för att förstå att argumentet till h[b.type] alltid kommer att motsvara exakt typen av b. Istället ser den att h[b.type] accepterar någon beståndsdel av BaseUnion, och att b är någon beståndsdel av BaseUnion, och opponerar sig mot möjligheten att de inte stämmer överens. Du kan hävda att de stämmer överens, vilket förmodligen är det bästa du kan göra:

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
}

Hoppas att det är till någon hjälp. Lycka till!

Svar #2

Jag var inte nöjd med det föreslagna kravet på att ha en union-klass som känner till alla de klasser/gränssnitt som utvidgar den, så jag tittade lite närmare på ett sätt att kringgå problemet.

Det är värt att notera att felet är relativt legitimt - systemet kan inte veta att det vi gör alltid är giltigt, så det talar om det för oss. Så vi måste i princip arbeta runt typsäkerheten - som jcalz gör med as ((b: B) => C)

Jag närmade mig det från andra hållet - tala om för kompilatorn att internt vet jag vad jag gör med typ-säkerheten vid registrering, istället för vid callback, men jag vill fortfarande korrekt typa hanterarna så att de härleds från basklassen.

Så här går vi:

Registrering/Disponent:

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);
    }
}

händelserna:

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,
    ) { }
}

handlaren:

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

föraren:

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

och slutligen, användare:

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

anteckningar :

  • lagts till globalt/platshållare för minimala exempelföremål
  • Jag gillar händelsedefinitionerna som klasser för den fina konstruktorn, användarvänligheten, tydligheten och säkerheten. Till skillnad från ett gränssnitt + en skapa-funktion, där alla måste skriva { type: 'xxx' } själva, eller de individuella variabelnamnen. Så bekvämt!
  • C i detta minimala exempel är void. Funktions returtyp är sekundär i förhållande till den faktiska typfrågan här.

Jag återbesöker detta svar ibland för att förbättra det när jag lär mig och utökar systemet och gör det säkrare:

  • Statiskamedlemmartyper (v3 - super förenklad)
  • typeguard-kontroll på subklasser (utan att ha förälder/barn-beroenden)
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;

Relaterade artiklar