Overview
When we left off with our Playlist.tracks
resolver, 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 GraphQL type. With so many differences between REST APIs, databases, and other data sources 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 GraphQL types
- Send a query that traverses between multiple object types
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 resolver function receives should be a Playlist
object (as returned by the previous resolver 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 GraphQL type.
{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
fields 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 resolver makes TypeScript angry. This violates the shape and data it thinks a Playlist
type should have!
"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 resolver functions to get the data we really want. These types are called models, and they model the shape of data we receive from our data sources.
Introducing models
GraphQL 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 resolver 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.
We see this scenario exactly with our Playlist.tracks
resolver: our type annotations presume that our Playlist.tracks
resolver receives what the previous resolver in the chain, Query.playlist
, returns: a Playlist
object, matching the shape defined in our GraphQL schema perfectly.
This is how the resolver 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 fields we gave to our Playlist
GraphQL type!
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 field's resolver 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
resolver!—can look vastly different.
When the shape of data going into and coming out of our resolvers 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 resolver functions.
Including models in the codegen process can definitely be confusing, so try to keep these two principles in mind:
- The types that we define in our GraphQL schema are meant to represent the shape of the data that we return to the client.
- The data that our resolvers are actually working with, as returned from our data source, or as passed from one resolver to another, might look different from the types defined in the GraphQL schema. 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.
- We'll define a model that represents an object of data from our REST API.
- We'll define the properties (along with their types) on these objects.
- We'll update our
codegen.ts
file with the models we want to be included in codegen, specifying them under themappers
property. - When we return to our resolvers and begin working with objects from the backend (that may or may not match our GraphQL 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.
// Represents a playlist object returned by the REST APIexport 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.
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
GraphQL 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.
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.
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
GraphQL type: id
, name
, explicit
, duration_ms
, and uri
.
{"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.
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.
export type TrackModel = {// TODO};
Inside of this type, we'll add the id
, name
, duration_ms
, explicit
, and uri
properties.
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
.
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 resolvers 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 resolvers 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
.
config: {contextType: "./context#DataSourceContext",mappers: {// TODO},},
Inside of the mappers
object, we'll define two keys, Playlist
and Track
to represent our GraphQL types. Then, we'll pass the path to the model we want to use for each, referencing each model with a #
.
mappers: {Playlist: "./models#PlaylistModel",Track: "./models#TrackModel"},
To learn more about using model types with the GraphQL 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
GraphQL 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.
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
GraphQL 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
.
export type TrackModel = {id: string;name: string;duration_ms: number;explicit: boolean;uri: string;};
{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 query for the Track.durationMs
query, we'll get null
. Let's take care of this by adding a new resolver just for this field 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
field to durationMs
, a common pattern you'll see to match naming conventions in GraphQL. All done!
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 query again.
query GetPlaylistDetails($playlistId: ID!) {playlist(id: $playlistId) {idnamedescriptiontracks {idnamedurationMsexplicituri}}}
With the following set in the Variables panel:
{ "playlistId": "6LB6g7S5nc1uVVfj00Kh6Z" }
Now, we should see that our query functions just as we expect: we see our specific playlist's details, along with a list of its track names!
Practice
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 GraphQL type a model should map to. - By mapping
PlaylistModel
to thePlaylist
type, our resolvers that expect to receive aPlaylist
object as theirparent
argument (such asPlaylist.tracks
) can access all of the properties that exist onPlaylistModel
without type errors. - Resolvers can be defined for every field 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 querying 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: GraphQL mutations.
Share your questions and comments about this lesson
This course is currently in
You'll need a GitHub account to post below. Don't have one? Post in our Odyssey forum instead.