How do you write flexible typescript types/interfaces for client-side documents?

Viewed 74

Let's say we have the following models:

const catSchema = new Schema({
  name: String,
  favoriteFood: { type: Schema.Types.ObjectId, ref: 'FoodType' },
});

const foodType = new Schema({
  name: String,
});

Here we can see that favoriteFood on the catSchema is a reference to another collection in our database.

Now, let's say that the getAllCats api does not populate the favoriteFood field because it isn't necessary and therefor just returns the reference id for foodType. The response from the api might look like this:

[
  {name: 'Fluffy', favoriteFood: '621001113833bd74d6f1fc8c'},
  {name: 'Meowzer', favoriteFood: '621001113833bd74d6f1fc4b'}
]

However, the getOneCat api DOES populate the favoriteFood field with the corresponding document. It might look like this:

{
  name: 'Fluffy',
  favoriteFood: {
    name: 'pizza'
  }
}

My question, how does one write a client side interface/type for my cat document?

Do we do this?

interface IFavoriteFood {
  name: string
}

interface ICat {
  name: string,
  favoriteFood: IFavoriteFood | string
}

Say we have a React functional component like this:

const Cat = (cat: ICat) => {
  return (
    <div>
      ${cat.favoriteFood.name}
    </div>
  )
}

We will get the following typescript error : "Property 'name' does not exist on type 'string | IFavoriteFood'. Property 'name' does not exist on type 'string'."

So, I have to do something like this to make typescript happy:

const Cat = (cat: ICat) => {
  return (
    <div>
      ${typeof cat.favoriteFood === 'string' ? 'favoriteFood is not populated': cat.favoriteFood.name}
    </div>
  )
}

Do I write two separate interfaces? One for the cat object with favoriteFood as a string for the objectId and one for cat with favoriteFood as the populated object?

interface IFavoriteFood {
  name: string
}

interface ICat {
  name: string,
  favoriteFood: string
}

interface ICatWithFavoriteFood {
name: string,
favoriteFood: IFavoriteFood
}

const Cat = (cat: ICatWithFavoriteFood) => {
  return (
    <div>
      ${cat.favoriteFood.name}
    </div>
  )
}

Would love to hear how people approach this in their codebase. Also open to being pointed to any articles/resources that address this issue.

1 Answers

This:

favoriteFood: IFavoriteFood | string

Is a bad idea and will lead to a lot of ugly code trying to sort out when it's one data type versus the other.

I think a better approach (and one I personally use a lot) would be:

favoriteFoodId: string
favoriteFood?: IFavoriteFood

So favoriteFoodId is always there, and is always a string. And a full favoriteFood object is sometimes there.

Now to use that value is a very simple and standard null check.

const foodName = cat.favoriteFood?.name ?? '- ice cream, probably -'

Note, this does mean changing you schema a bit so that foreign keys are suffixed with Id to not clash with the keys that will contain the actual full association data.


You could extend this to the two interface approach as well, if you wanted to lock things down a bit tighter:

interface ICat {
  name: string,
  favoriteFoodId: string
  favoriteFood?: null // must be omitted, undefined, or null
}

interface ICatWithFavoriteFood extends ICat {
  favoriteFood: IFavoriteFood // required
}

But that's probably not necessary since handling nulls in your react component is usually cheap and easy.

Related