I'm trying to return an object from a factory function and give it an exact type based on a discriminated union. I've summarised into a much simplified code sample below, but whilst hovering over the newSquare object shows that the complier knows that it is a Square, I'm getting an error on return new Circle of:
Type 'Circle' is not assignable to type 'DiscriminateUnion<Circle, "kind", T> | DiscriminateUnion<Square, "kind", T>'. Type 'Circle' is not assignable to type 'DiscriminateUnion<Square, "kind", T>'.(2322)
Can someone explain what I'm doing wrong? Many thanks!
type ShapeKind = 'circle' | 'square'
class Circle{
kind: "circle" = 'circle';
radius: number;
constructor() {
this.radius = 4
}
}
class Square{
kind: "square" = 'square';
sideLength: number;
constructor() {
this.sideLength = 4
}
}
type Shape = Circle | Square;
type DiscriminateUnion<T, K extends keyof T, V extends T[K]> =
T extends Record<K, V> ? T : never
function makeShape<T extends ShapeKind>(shapeKind: T): DiscriminateUnion<Shape, 'kind', T> {
switch (shapeKind) {
case 'circle':
return new Circle()
case 'square':
return new Square()
default:
throw Error('not valid shape')
}
}
const newSquare = makeShape('square')
That's a well known problem. The compiler has no way to verify that concrete returned type is compatible with the conditional type depending on generic type parameter. You may read about the exact limitations here microsoft/TypeScript/issues/33912.
Beside the obvious type assertion of the returned result:
function makeShape<T extends ShapeKind>(shapeKind: T): DiscriminateUnion<Shape, 'kind', T> {
switch (shapeKind) {
case 'circle':
return new Circle() as any // or `as DiscriminateUnion<Shape, 'kind', T>`
case 'square':
return new Square() as any
default:
throw Error('not valid shape')
}
}
The common goto for such cases is function overloads:
function makeShape<T extends ShapeKind>(shapeKind: T): DiscriminateUnion<Shape, 'kind', T>
function makeShape(shapeKind: ShapeKind) : Shape {
switch (shapeKind) {
case 'circle':
return new Circle()
case 'square':
return new Square()
default:
throw Error('not valid shape')
}
}
/*
const newSquare: Square
*/
const newSquare = makeShape('square')
While there are no errors here the implementation function actually doesn't check whether the returned type is compatible with function overloads and you can return it completely wrong:
function makeShape<T extends ShapeKind>(shapeKind: T): DiscriminateUnion<Shape, 'kind', T>
function makeShape(shapeKind: ShapeKind) : Shape {
switch (shapeKind) {
case 'circle':
return new Square() // wrong returned type but no error
case 'square':
return new Circle() // wrong returned type but no error
default:
throw Error('not valid shape')
}
}
Trying to impose strict type safety you may go further and try to check the returned type compatibility inside each of the case
arms. Credits for the brilliant idea for in-function type checking goes to jcalz
:
function makeShape<T extends ShapeKind>(shapeKind: T): DiscriminateUnion<Shape, 'kind', T>
function makeShape(shapeKind: ShapeKind) : Shape {
switch (shapeKind) {
case 'circle':
const ret1 = new Square()
const tmp1: typeof ret1 = (false as true) && makeShape(shapeKind) // errors now
return ret1
case 'square':
const ret2 = new Circle()
const tmp2: typeof ret2 = (false as true) && makeShape(shapeKind) // errors now
return ret2
default:
throw Error('not valid shape')
}
}
It still has some artefacts in runtime and while that type checking makeShape
call will not happen actually due to short circuting nature of &&
. This looks kind of ugly and a requires a lot of ceremony to my taste.
I belive for non-critical paths annotating retuned values as as any
is completely acceptible way.