Gravatar of Jeroen Jeroen Pelgrims

Typescript flexible entity type

Posted on in typescript, software-development

TLDR of what's going on in this post: creating a typescript type definition for a database entity that has a property which can be either an array of references to other entities OR that can be an array of those entities nested in it.
Click here to skip the introduction and go straight to the solution.

On a project I worked on we used API Platform for the backend and React + Typescript for the frontend.

Database diagram§

Assume we have the following database diagram:

Example database diagram

With the following data:

Film

titlerating
Interstellar10

Role

name
Cooper

Actor

namebirthDate
Matthew McConaughey1969-11-4

API platform endpoints§

In API platform you can define which REST endpoints you want to generate for which entities. In this example we'll look at GET /films.

If you'd execute that GET you'd get a JSON response similar to this:

{
  "@context": "/contexts/Film",
  "@id": "/films",
  "@type": "hydra:Collection",
  "hydra:member": [
    {
      "@id": "/films/1",
      "@type": "http://schema.org/Film",
      "title": "Interstellar",
      "rating": 10,
      "roles": ["/roles/1"]
    }
  ],
  "hydra:totalItems": 1,
  "hydra:view": {
    "@id": "/books?page=1",
    "@type": "hydra:PartialCollectionView",
    "hydra:first": "/books?page=1",
    "hydra:last": "/books?page=2",
    "hydra:next": "/books?page=2"
  }
}

You'll see that every Film has a property "roles" which is an array of IRIs pointing to the related Role entities.

Modeling a Typescript type for the response§

If we'd model types for this response in Typescript, a generic approach could be: (I left out some of the fields I won't use)

type EntityReference = string;

type Entity = {
  "@id": EntityReference;
};

type HydraCollectionResponse<T extends Entity> = {
  "hydra:totalItems": number;
  "hydra:view": {
    "hydra:first": string;
    "hydra:last": string;
    "hydra:next": string;
  };
  "hydra:member": T[];
};

The Film entity would look like this:

type Film = Entity & {
  title: string;
  rating: number;
  roles: EntityReference[];
};

The full type for a collection response of Films would then just be:

type FilmsResponse = HydraCollectionResponse<Film>;

Embedding relations§

API Platform also supports the nesting/embedding of related entities in the response.
So that array of "roles" which are now IRIs could be full "Role" objects if we want it to be.
This is done by adding a parameter in the GET request to define which entities should be embedded.
GET /films?embed[]=roles

Now instead of an array of IRIs, we'll receive an array of Role objects for the "roles" property:

{
  ...
  "hydra:member": [
    {
      "@id": "/films/1",
      "@type": "http://schema.org/Film",
      "title": "Interstellar",
      "rating": 10,
      "roles": [
        {
          "name": "Cooper",
          "actor": "actors/1"
        }
      ]
    }
  ],
  ...
}

To model this in Typescript we could make a completely new type FilmWithRole where the roles property is Role[] instead of EntityReference[], but then we'd have to re-define every other property as well.
It would be better to re-use the definition we already have.

Your first thought might be to just "overwrite" that single property with a different definition:

type FilmWithRole = Film & {
  roles: Role[];
};

But that won't work. Because then the roles property is of type EntityReference[] & Role[]. Which is impossible to fulfill.

A generics solution§

We need to define a type for Film where the roles property can be defined by a parameter.
So that one place we can say "It's just a list of references" and in an other place we can say "It's a list of Role objects".
This we can do with generics:

interface WithRoleReferences {
  roles: EntityReference[];
}

interface WithRoles {
  roles: Role[];
}

type Film<T = WithRoleReferences> = Entity &
  T & {
    title: string;
    rating: number;
  };

This way when we use just use the Film type, it's exactly the same as before. roles is an array of references to roles.
But now, when we embed the Role entities, we can also easily use the type with a generic parameter: Film<WithRoles>.
In this case the "roles" property will be an array of Role objects.

What if we also want the actors?§

Then the embed query parameter in the get call would also include the actors entity:
/films?embed[]=roles&embed[]=actors

And the modeling of the types would work exactly the same as before.

type Actor = Entity & {
  name: string;
  birthDate: string;
};

interface WithActorReference {
  actor: EntityReference;
}

interface WithActor {
  actor: Actor;
}

type Role<T = WithActorReference> = Entity &
  T & {
    name: string;
  };

interface WithRoles<
  T extends WithActor | WithActorReference = WithActorReference
> {
  roles: Role<T>[];
}

You can then define a Film that also has the Roles embedded and the Actors in each role:
Film<WithRoles<WithActor>>

Caveat§

You'll run into a small issue when you have more than 1 property you need to be able to switch types of.
Say if our film would also have a reference to Showings:

interface WithRoleReferences {
  roles: EntityReference[];
}

interface WithRoles {
  roles: Role[];
}

interface WithShowingReferences {
  showings: EntityReference[];
}

interface WithShowings {
  showings: Showing[];
}

type Film<T = WithRoleReferences & WithShowingReferences> = Entity &
  T & {
    title: string;
    rating: number;
  };

You'd expect to be able to use Film<WithShowings> to only embed the showings objects.
But now the roles property isn't defined on that type!
The correct way to use it would be Film<WithShowings & WithRoleReferences>.

This post is written by Jeroen Pelgrims, an independent software developer who runs Digicraft.eu.

Hire Jeroen