I have an is-a relationship type of thing going on. I'm modeling this through factories that create factories, but I could have also use inheritance (except, I would like to avoid inheritance).
Here's a slightly more concrete example of what's going on:
function createShapeCategory({ getArea }) {
const create = data => ({
getArea: () => getArea(data),
data,
})
return { create }
}
const rectangle = createShapeCategory({
getArea: ({ width, height }) => width * height
})
const triangle = createShapeCategory({
getArea: ({ width, height }) => width * height / 2
})
const myRectangle = rectangle.create({ width: 2, height: 3 })
const myTriangle = triangle.create({ width: 2, height: 3 })
console.log(myRectangle.getArea(), myTriangle.getArea())
Run code snippet
Expand snippet
The question is - how do I make a Rectangle and a Triangle type? Both myRectangle and myTriangle will be the same type in the eyes of typeScript, because they have the same shape, which is a problem. What if I wanted to make some trig functions specific to triangles, that only took triangles in as a parameter? You could easily accidentally pass in a rectangle as well.
To illustrate what I'm talking about:
interface IRectangle {
getArea: () => number
}
interface ITriangle {
getArea: () => number
}
// ... define createShapeCategory, rectangle, and triangle ...
const myTriangle: ITriangle = triangle.create({ width: 2, height: 3 })
const circumferenceOfRect = (rect: IRectangle) => ...
circumferenceOfRect(myTriangle) // This is legal :(
Just to show that this isn't an issue directly related to my factory-of-factory approach, here's the same chunk of code, modeled using inheritance instead. It suffers from the same issues.
Show code snippet
I have come up with one possible solution, but it's pretty gross. I can attach a unique symbol to each instance, then make the symbols part of the type. This will use TypeScript's "unique symbol" feature, which is very constrained - I would have to make the symbol first, pass it into the factory, and make the symbol part of the type from outside. I can't have the factory make the symbol for me, due to the fact that you can't return something with the type "unique symbol".
Current ugly solution:
interface Shape<S extends symbol, D> {
sentinel: S
getArea: () => number
data: D
}
interface createShapeCategoryOpts<S extends symbol, D> {
sentinel: S
getArea: (data: D) => number
}
function createShapeCategory<S extends symbol, D>({ sentinel, getArea }: createShapeCategoryOpts<S, D>) {
const create = (data: D): Shape<S, D> => ({
sentinel,
getArea: () => getArea(data),
data,
})
return { create }
}
const rectangleSentinel = Symbol('Rectangle')
interface RectangleData { width: number, height: number }
type Rectangle = Shape<typeof rectangleSentinel, RectangleData>
const rectangle = createShapeCategory<typeof rectangleSentinel, RectangleData>({
sentinel: rectangleSentinel,
getArea: ({ width, height }) => width * height
})
const triangleSentinel = Symbol('Triangle')
interface TriangleData { width: number, height: number }
type Triangle = Shape<typeof triangleSentinel, TriangleData>
const triangle = createShapeCategory<typeof triangleSentinel, TriangleData>({
sentinel: triangleSentinel,
getArea: ({ width, height }) => width * height / 2
})
const myRectangle = rectangle.create({ width: 2, height: 3 })
const myTriangle = triangle.create({ width: 2, height: 3 })
console.log(myRectangle.getArea(), myTriangle.getArea())
const rectOnlyFn = (rect: Rectangle) => 0
rectOnlyFn(myTriangle) // Yay! An error!
Is there a better way to do this? I feel like it should be a common problem, but I couldn't find much help on the Internet. Or, are there at least some stuff I could do to clean up that code a bit?