Home > database >  TypeScript: different types for objects with the same shape
TypeScript: different types for objects with the same shape

Time:10-02

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())

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.

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?

CodePudding user response:

I would usually use something like a type property which is just a string. Numeric literals/enums can also be used.

In general I would let the compiler infer as many of the generics as possible, so when calling a function like createShapeCategory you do not have to spell out all the types.

Example using a string type, inferring as much as possible:

interface Shape<S extends string, D> {
  type: S
  getArea: () => number
  data: D
}

interface createShapeCategoryOpts<S extends string, D> {
    type: S
    getArea: (data: D) => number
}
function createShapeCategory<S extends string, D>(
  { type, getArea }: createShapeCategoryOpts<S, D>,
) {
  const create = (data: D): Shape<S, D> => ({
    type,
    getArea: () => getArea(data),
    data,
  })

  return { create }
}

interface RectangleData { width: number, height: number }
type Rectangle = Shape<'rectangle', RectangleData>
const rectangle = createShapeCategory({
  type: 'rectangle',
  getArea: ({ width, height }: RectangleData) => width * height
})

interface TriangleData { width: number, height: number }
type Triangle = Shape<'triangle', TriangleData>
const triangle = createShapeCategory({
  type: 'triangle',
  getArea: ({ width, height }: TriangleData) => 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!

You can also use a "type map" of sorts if you already know all kinds of shapes you will have, to get rid of one of the generic parameters, as it can be read from the map:

type ShapeMap = {
  // Types can be extracted as necessary
  rectangle: { width: number, height: number },
  triangle: { width: number, height: number },
}
type ShapeType = keyof ShapeMap;

interface Shape<S extends ShapeType> {
  type: S
  getArea: () => number
  data: ShapeMap[S]
}

interface createShapeCategoryOpts<S extends ShapeType> {
    type: S
    getArea: (data: ShapeMap[S]) => number
}
function createShapeCategory<S extends ShapeType>(
  { type, getArea }: createShapeCategoryOpts<S>,
) {
  const create = (data: ShapeMap[S]): Shape<S> => ({
    type,
    getArea: () => getArea(data),
    data,
  })

  return { create }
}

type Rectangle = Shape<'rectangle'>
const rectangle = createShapeCategory({
  type: 'rectangle',
  getArea: ({ width, height }) => width * height
})

type Triangle = Shape<'triangle'>
const triangle = createShapeCategory({
  type: 'triangle',
  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!

Same idea using an enum instead of a string:

  • Related