3
votes

I'm defining a class that generically mutates one object type to another, that has a very simple interface: id:string, type:string

I want the class definition to signify that it will return AN assembly and the instantiation to direct WHICH assembly it will return.

Compilation Error (on line 79 return assembly;):

Type 'Assembly' is not assignable to type 'Out'. 'Assembly' is assignable to the constraint of type 'Out', but 'Out' could be instantiated with a different subtype of constraint 'Assembly'.(2322)

Typescript Code:

interface IAssemblyRequirements {
    id: string;
    type: string;
}

interface IData extends IAssemblyRequirements {
    attributes: any;
}

interface IFooBarData extends IData {
    attributes: {
        start: string;
    }
}

interface IBazBarData extends IData {
    attributes: {
        chickens: number;
    }
}

const foobarData: IFooBarData = {

    id: "1",
    type: "foobar",
    attributes: {
        start: "Dec 1, 2020"
    }

}

const bazbarData:IBazBarData = {

    id: "2",
    type: "bazbar",
    attributes: {
        chickens: 9
    }

}

class Assembly implements IAssemblyRequirements {

    id:string;
    type:string;

    constructor(data: IData) {
        this.id = data.id;
        this.type = data.type;
    }

}

class FooBar extends Assembly {
    start:Date;
    constructor(data: IFooBarData) {
        super(data);
        this.start = new Date(data.attributes.start);
    }
}

class BazBar extends Assembly {
    chickens: number;
    constructor(data: IBazBarData) {
        super(data);
        this.chickens = data.attributes.chickens;
    }
}

const typeAssemblers:{ [key:string]: typeof Assembly } = {
    foobar: FooBar,
    bazbar: BazBar
}

class Assembler<In extends IData, Out extends Assembly> {

    assemble(input: In): Out {
        const assembly = new typeAssemblers[ input.type ]( input );
        return assembly;
    }

}

const assembler = new Assembler<IFooBarData, FooBar>();
const assembly = assembler.assemble(foobarData);
1
Well, your assemble() method always returns an Assembly. Although it should return an Out, which can be any type chosen by the user of the class (as long as that type implements IAssemblyRequirements) . So it doesn't compile. If a method declares it returs an Out, it must return an Out. Why is your class generic? It doesn't use any of its generic types.JB Nizet
The problem here is that a user of your class can choose any subtype of IAssemblyRequirements and your method promises to return their chosen subtype; but you don't take any inputs which depend on that subtype, and the type parameter itself has no representation at runtime, so the method has no way to actually produce a value of the right subtype. What if somebody creates a new Assembler<IAssemblyRequirements, never>(), and calls your assemble method, which promises in that case to return never? The only way for that to type-check is if you unconditionally throw an exception.kaya3
@JBNizet I oversimplified my code sample first time through, the new one is more expanded, but still simplified considerably to isolate the issue.Bradley

1 Answers

3
votes

You can certainly represent the relationship between the input and output types with generics, but the compiler will generally not be able to verify that your implementation of Assembler.assemble() adheres to it. That would require support something I've been calling correlated record types; for now, a construct like new typeAssemblers[input.type](input) will probably need some type assertions. So, be prepared for that in what follows.


One thing missing about your type definitions is that IFooBarData and IBazBarData have type properties of the type string, but your implementation would only work if those are narrowed to the correct "foobar" and "bazbar" string literal types; otherwise someone could make an IFooBarData with a type property like "oopsie" and the implementation would explode at runtime. So here are some changes to those definitions:

interface IFooBarData extends IData {
    attributes: {
        start: string;
    }
    type: "foobar"
}

interface IBazBarData extends IData {
    attributes: {
        chickens: number;
    }
    type: "bazbar"
}

Another problem is that annotating typeAssemblers with the type { [key:string]: typeof Assembly } throws away a lot of type information. The compiler will have no idea that typeAssemblers.foobar holds FooBar and that typeAssemblers.oopsie doesn't exist. Instead I'd suggest not annotating typeAssemblers at all, to infer a narrower type for it. We might as well give a name to that narrowed type, also:

const typeAssemblers = {
    foobar: FooBar,
    bazbar: BazBar
}
type TypeAssemblers = typeof typeAssemblers;

This type is inferred by the compiler as {foobar: typeof FooBar; bazbar: typeof BazBar;}.


Finally, the Assembler class really should only have a single generic type parameter. The relationship between the In type and the Out type is set by TypeAssemblers; a user should not be allowed to ask for In being IBazBarData while Out is a FooBar. So let's just use the In type (I'm calling it I) and from that we can compute the output type as InstanceType<TypeAssemblers[I["type"]]> (which means: the I input has a type property which can be used to index into TypeAssemblers, which will produce a constructor whose instance type is what we will output).

Here's Assembler, with those type assertions I warned about earlier:

class Assembler<I extends IFooBarData | IBazBarData> {
    assemble(input: I) {
        return new typeAssemblers[input.type](
            input as any
        ) as InstanceType<TypeAssemblers[I["type"]]>;
    }
}

And now finally we can test it:

const assembler = new Assembler<IFooBarData>();
const assembly = assembler.assemble(foobarData); // FooBar
console.log(assembly.start.getFullYear()); // 2020

That looks good and compiles; the compiler knows that assembly is a FooBar.


Okay, hope that helps give you some direction; good luck!

Playground link to code