Typescript flexible entity type
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:
With the following data:
Film
title rating Interstellar 10
Role
name Cooper
Actor
name birthDate Matthew McConaughey 1969-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 Role
s embedded and the Actor
s 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 Showing
s:
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>
.