2
\$\begingroup\$

I want to create a hierarchy of Typescript interfaces because I think classes would be an overkill in my case. The main obstacle is checking if an object has a specific type. I solved it this way:

enum AnimalId {
  Mammal = 1,
  Fish = 3,
}

interface BaseAnimal {
  id: number
  categoryId: AnimalId
  name: string
}

interface Fish extends BaseAnimal {
  categoryId: AnimalId.Fish
  fins: number
}

interface Mammal extends BaseAnimal {
  categoryId: AnimalId.Mammal
  speed: number
}

type Animal = Fish | Mammal

const fish1: Fish = {
  id: 1,
  categoryId: AnimalId.Fish,
  name: 'trout',
  fins: 5,
}

const fish2: Fish = {
  id: 2,
  categoryId: AnimalId.Fish,
  name: 'salmon',
  fins: 8,
}

const mammal1: Mammal = {
  id: 3,
  categoryId: AnimalId.Mammal,
  name: 'lion',
  speed: 80,
}

const animals: Animal[] = [fish1, fish2, mammal1]

/* eslint-disable no-case-declarations */
for (const animal of animals) {
  let score = 0
  switch (animal.categoryId) {
    case AnimalId.Fish:
      score += animal.fins
      break
    case AnimalId.Mammal:
      score += animal.speed
      break
    default:
      break
  }
}

There are two things I don't like with the current approach:

  1. I have to create a Union type Animal with all specific Animal types. I would rather just use BaseAnimal instead.

  2. If I want to create a new specific Animal e.g. of type Fish I also have to set categoryId: AnimalId.Fish. It would be great if this is done implicitly:

const fish2: Fish = {
  id: 2,
  name: 'salmon',
  fins: 8,
}
\$\endgroup\$

1 Answer 1

1
\$\begingroup\$

Your questions:

  1. You could use BaseAnimal, but that would only give you the base properties. There's no way I know of to make this work other than casting, but that would make it unsafe. The discriminated union is quite nice imo, it's very easy to understand.
  2. Since typescript is just types, there is no way for it to actually set a value on an object. You'd need a constructor for this. In your case you could just add factory functions for the different animals.

I can offer you some alternative ways to define these types (if you need inspiration):

  • Remove the enum (there are various quirks and issues, and they're not always type safe either. Enough has been written about this that it should be easy to google)
  • Replace interface + extends with union types. With this you can compose models however you like (as opposed to be restricted to a single parent base model)
  • Put the switch in a function, and remove the default switch-case. Typescript will make sure you have covered all cases (as long as you have a return type and strictNullChecks is enabled, or you have eslint rules for it)
type BaseAnimal = {
    id: number
    name: string
}

type Fish = {
    type: 'fish'
    fins: number
} & BaseAnimal

type Mammal = {
    type: 'mammal'
    speed: number
} & BaseAnimal

type Animal = Fish | Mammal

const fish1: Fish = {
    type: 'fish',
    id: 1,
    name: 'trout',
    fins: 5,
}

const animals: Animal[] = [fish1]

const getScore = (animal: Animal): number => {
    switch (animal.type) {
        case 'fish': return animal.fins
        case 'mammal': return animal.speed
    }
}

let score = 0
for (const animal of animals) {
    score += getScore(animal)
}

Or you can make things even more composable:

type BaseAnimal = {
    id: number
    name: string
}

type FishProps = {
    fins: number
}

type MammalProps = {
    speed: number
}

type Fish = BaseAnimal & FishProps & { type: 'fish' }
type Mammal = BaseAnimal & MammalProps & { type: 'mammal' }

type Animal = Fish | Mammal

Or less composable if you want it to be more readable (this is often the best solution, imo. It's way easier to read):

type Fish = {
    type: 'fish'
    id: number
    name: string
    fins: number
}
type Mammal = { 
    type: 'mammal'
    id: number
    name: string
    speed: number
}

type Animal = Fish | Mammal

If you actually want the animal type values you can create an enum equivalent:

const animalTypes = {
    fish: 'fish',
    mammal: 'mammal'
} as const

type AnimalType = typeof animalTypes[keyof typeof animalTypes]

If you want to avoid writing the type, create factory functions:

const createFish = (fishData: Omit<Fish, 'type'>): Fish => ({ ...fishData, type: 'fish' })

createFish({ fins: 5, id: 1, name: 'trout' })
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.