13. Using models
10m

Overview

When we left off with our Playlist.tracks , TypeScript threw out a big error. Inside of the resolver function, we were attempting to access properties from the REST API response that aren't present on our corresponding type. With so many differences between REST APIs, databases, and other we consume, we're bound to run into these kinds of errors. But we can tackle this problem using models.

In this lesson, we will:

  • Walk through the process of defining backend data models and "mapping" them to types
  • Send a that traverses between multiple s

From backend to schema: the shape of our data

Here's the error we're seeing in the terminal:

Property 'items' does not exist on type 'Track[]'.

TypeScript is trying to help us out here: it knows that the parent object this function receives should be a Playlist object (as returned by the previous in the chain). That is, it should share all of the same properties that we gave to our Playlist type in our schema, like id, name, description, and tracks.

The problem is that a playlist object from the REST API doesn't look exactly like our type.

The Playlist object returned by the REST API
{
description: 'Infuse flavor into your kitchen. This playlist merges zesty tunes with culinary vibes, creating a harmonious background for your cooking escapades. Feel the synergy between music and the zest of your creations.',
id: '6LB6g7S5nc1uVVfj00Kh6Z',
name: 'Zesty Culinary Harmony',
tracks: {
href: 'https://api.spotify.com/v1/playlists/6LB6g7S5nc1uVVfj00Kh6Z/tracks?offset=0&limit=100&locale=en-US,en;q=0.9',
items: [Array],
limit: 100,
next: null,
offset: 0,
previous: null,
total: 3
},
// other properties
}

The object does indeed have the id, name, and description we need, but its tracks property looks a little strange. It's not an array of Track objects; it's an object, with an items property that we need to dig deeper into.

So even though this items property exists on our objects from the REST API, referencing it in our makes TypeScript angry. This violates the shape and data it thinks a Playlist type should have!

The Playlist type in the GraphQL schema
"A curated collection of tracks designed for a specific activity or mood."
type Playlist {
"The ID for the playlist."
id: ID!
"The name of the playlist."
name: String!
"Describes the playlist, what to expect and entices the user to listen."
description: String
"The tracks of the playlist."
tracks: [Track!]! # THIS is what TypeScript expects from the `tracks` property
}

So, we're running into a mismatch: the shape that our data takes when returned from the REST API differs substantially from the shape that our schema dictates it should take.

Fortunately, we don't need to tweak our schema to match the extra properties and nested objects we get from our REST API. Instead, we can pass our codegen.ts config object a new property called mappers. With mappers, we can provide a picture of what our REST API responses look like, and the kinds of properties (outside of those defined in the schema) we might need to manipulate or traverse in our functions to get the data we really want. These types are called models, and they model the shape of data we receive from our .

Introducing models

is powerful because it lets us use our schema to define how all of our objects relate: how they interact, and how we get from one to the next. By moving from object to object, we can construct really robust, detailed queries that fetch everything we need in a single client request.

It's the heavy job of the functions to make this magic possible: they need the freedom to receive and manipulate data that is oddly-shaped or looks nothing like the types in our schema, and perform the logic needed to return the types we do expect.

A diagram showing data coming into a resolver taking disparate shapes, and the data that is returned by the resolver, aligning to the GraphQL schema

We see this scenario exactly with our Playlist.tracks : our type annotations presume that our Playlist.tracks receives what the previous resolver in the chain, Query.playlist, returns: a Playlist object, matching the shape defined in our perfectly.

A diagram showing how our resolver should behave according to type annotations

This is how the should work according to our type annotations—but in reality, the response we get from the REST API for each playlist contains many more properties and nested objects than the we gave to our Playlist type!

A diagram showing what our resolver actually receives from the previous resolver in the chain

To maintain type-safety, we need to clarify how our types of data differ between what resolvers receive from data sources and what resolvers return to clients. We expect a 's to return data in the shape we defined in the schema, but the raw data the resolver retrieves—as we've seen with our Playlist.tracks !—can look vastly different.

When the shape of data going into and coming out of our is not a perfect match, we can define models for our backend data objects. Using our codegen config file, we can then map those models to the type definitions TypeScript generates for the type of data actually coming into the functions.

A diagram showing the type of data going into the resolver more accurately described in terms of the properties it contains

Including models in the codegen process can definitely be confusing, so try to keep these two principles in mind:

  1. The types that we define in our are meant to represent the shape of the data that we return to the client.
  2. The data that our are actually working with, as returned from our , or as passed from one to another, might look different from the types defined in the . We use models to represent the actual shape of the data.

Adding the mappers property

Here's how we'll use models and the mappers property to tackle the problem.

  1. We'll define a model that represents an object of data from our REST API.
  2. We'll define the properties (along with their types) on these objects.
  3. We'll update our codegen.ts file with the models we want to be included in codegen, specifying them under the mappers property.
  4. When we return to our and begin working with objects from the backend (that may or may not match our types), TypeScript will understand what these objects look like and what kinds of properties they have. That means zero type errors!

Let's get to it!

Step 1: Defining models

We'll create a new file in the src directory called models.ts.

📂 src
┣ 📂 datasources
┣ 📄 context.ts
┣ 📄 graphql.d.ts
┣ 📄 index.ts
┣ 📄 models.ts
┣ 📄 resolvers.ts
┣ 📄 schema.graphql
┗ 📄 types.ts

Here, we'll start by defining PlaylistModel to represent a playlist object returned by the REST API.

models.ts
// Represents a playlist object returned by the REST API
export type PlaylistModel = {};

Step 2: Setting properties

We know from the REST API documentation for a playlist object that we can access the id, name, and description values immediately. We'll define those here, along with their data types.

models.ts
export type PlaylistModel = {
id: string;
name: string;
description: string;
};

Now we can deal with the property that doesn't align with what we expect from our Playlist type. We'll add a new key, tracks. Following the structure of our REST API response, we'll make this an object with an items key.

models.ts
export type PlaylistModel = {
id: string;
name: string;
description: string;
tracks: {
items: // TODO
}
};

The items key in the REST API response holds an array of objects. Each of these objects has a track property, where most of the data we want for each track object lives.

models.ts
export type PlaylistModel = {
id: string;
name: string;
description: string;
tracks: {
items: {
track: {
// TODO
};
}[];
};
};

Inside of each of these track objects is where we'll find the data we need to fulfill our Track type: id, name, explicit, duration_ms, and uri.

An example object in the items array
{
"items": [
{
"added_at": "2024-01-17T22:39:23Z",
"added_by": {...},
"is_local": false,
"track": {
"id": "2epbL7s3RFV81K5UhTgZje",
"name": "Lemon Tree",
"uri": "spotify:track:2epbL7s3RFV81K5UhTgZje",
"explicit": false,
"duration_ms": 191026,
// other track properties
}
}
/* additional track objects */
]
}

With that, we can finish off our PlaylistModel definition.

models.ts
export type PlaylistModel = {
id: string;
name: string;
description: string;
tracks: {
items: {
track: {
id: string;
name: string;
duration_ms: number;
explicit: boolean;
uri: string;
};
}[];
};
};

This accurately describes the shape of each playlist object we get from the REST API's /browse/featured-playlists, but it's getting to be quite large. Consider the properties contained under track: we're likely to use objects of this shape more and more as we continue to develop this application, so it will be useful to have it as a cleaner, more refined definition of what a track object looks like from our REST API.

Let's create a new type, TrackModel, to hold these properties for each discrete track object.

models.ts
export type TrackModel = {
// TODO
};

Inside of this type, we'll add the id, name, duration_ms, explicit, and uri properties.

models.ts
export type TrackModel = {
id: string;
name: string;
duration_ms: number;
explicit: boolean;
uri: string;
};

With our properties extracted out into their own type, we can simplify our PlaylistModel definition. We'll remove the object containing the track properties, and replace it with the name of our new type, TrackModel.

models.ts
export type PlaylistModel = {
id: string;
name: string;
description: string;
tracks: {
items: {
track: TrackModel;
}[];
};
};

Fantastic! That leaves us with two clear types: one representing an object of playlist data, and another representing track data. This gives us everything we need to equip our to work with the data from our REST API.

Step 3: Updating codegen.ts with our models

Our models are done: both PlaylistModel and TrackModel help us capture the shape of the objects our will actually be working with when they receive responses from the REST API.

Next, jump back into codegen.ts and add a mappers property just below the line defining contextType.

codegen.ts
config: {
contextType: "./context#DataSourceContext",
mappers: {
// TODO
},
},

Inside of the mappers object, we'll define two keys, Playlist and Track to represent our types. Then, we'll pass the path to the model we want to use for each, referencing each model with a #.

codegen.ts
mappers: {
Playlist: "./models#PlaylistModel",
Track: "./models#TrackModel"
},

To learn more about using model types with the Code Generator, check out this article on better type safety in your resolvers.

Step 4: Fixing up the backend

Now, we need to return to our datasources/spotify-api.ts file.

Previously, we gave each of our class' methods a type annotation that used the Playlist or the Track type, as generated in types.ts. But we know now from the shape of our REST API responses that both our Playlist and Track types don't actually describe the shape of the responses we get from these endpoints.

Instead, we'll update all of the instances of Playlist to refer to our PlaylistModel type; and update all instances of Track to be TrackModel instead.

spotify-api.ts
import { RESTDataSource } from "@apollo/datasource-rest";
import { PlaylistModel, TrackModel } from "../models";
export class SpotifyAPI extends RESTDataSource {
baseURL = "https://spotify-demo-api-fe224840a08c.herokuapp.com/v1/";
async getFeaturedPlaylists(): Promise<PlaylistModel[]> {
const response = await this.get<{
playlists: {
items: PlaylistModel[];
};
}>("browse/featured-playlists");
return response?.playlists?.items ?? [];
}
getPlaylist(playlistId: string): Promise<PlaylistModel> {
return this.get(`playlists/${playlistId}`);
}
async getTracks(playlistId: string): Promise<TrackModel[]> {
const response = await this.get<{ items: { track: TrackModel }[] }>(
`playlists/${playlistId}/tracks`
);
return response?.items?.map(({ track }) => track) ?? [];
}
}

Let's stop and restart our server so that everything can get back up and running with our new codegen configuration.

npm run dev

Accounting for Track.durationMs

One last step! You might have noticed a discrepancy between our Track type and the TrackModel we just defined.

TrackModel reflects the shape of a track object from our REST API, and we'll notice that it has a duration_ms property, rather than durationMs.

models.ts
export type TrackModel = {
id: string;
name: string;
duration_ms: number;
explicit: boolean;
uri: string;
};
Example track object from REST API
{
id: "2epbL7s3RFV81K5UhTgZje"
name: "Lemon Tree",
explicit: false,
duration_ms: 191026,
uri: "spotify:track:2epbL7s3RFV81K5UhTgZje"
}

This is a small inconsistency, but it means that if we try to for the Track.durationMs , we'll get null. Let's take care of this by adding a new just for this in our resolvers.ts. Its job will be to receive the parent track object, and return its duration_ms property.

This effectively "renames" the duration_ms to durationMs, a common pattern you'll see to match naming conventions in . All done!

resolvers.ts
Track: {
durationMs: (parent) => parent.duration_ms
},

Putting everything together

The moment of truth! Restart your server, then jump back to Explorer.

Let's try running that again.

query GetPlaylistDetails($playlistId: ID!) {
playlist(id: $playlistId) {
id
name
description
tracks {
id
name
durationMs
explicit
uri
}
}
}

With the following set in the Variables panel:

{ "playlistId": "6LB6g7S5nc1uVVfj00Kh6Z" }

Now, we should see that our functions just as we expect: we see our specific playlist's details, along with a list of its track names!

Practice

What is the primary purpose of using mappers in our codegen config file?

Key takeaways

  • We can use models to represent the shape of our backend objects.
  • In the codegen process, we use the mappers property to specify which type a model should map to.
  • By mapping PlaylistModel to the Playlist type, our that expect to receive a Playlist object as their parent (such as Playlist.tracks) can access all of the properties that exist on PlaylistModel without type errors.
  • can be defined for every in our schema. When a resolver exists for a particular field on a type, responsibility for returning that data is automatically delegated to it.

Up next

So we've got down, but what's next? What happens when we actually want to change our data in the backend? For that, we need to delve into our final topic of this course: .

Previous

Share your questions and comments about this lesson

This course is currently in

beta
. Your feedback helps us improve! If you're stuck or confused, let us know and we'll help you out. All comments are public and must follow the Apollo Code of Conduct. Note that comments that have been resolved or addressed may be removed.

You'll need a GitHub account to post below. Don't have one? Post in our Odyssey forum instead.