12. Using mappers
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 mappers.

In this lesson, we will:

  • Walk through the process of defining and applying custom mappers 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.

Introducing mappers

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.

We're running into this with our Playlist and Track types: the responses from the REST API have many more properties and nested objects than the clean types we defined in our schema.

To maintain type-safety, we need a way to bridge the gap between the shape of what we get from our data sources and the shape that we return to clients. In many cases, these can be 1:1; but far more frequently, we'll see huge disparities in how (such as databases, or REST APIs) return data and how we actually want to use it in our client. By defining models for our backend data objects and mapping them to our types, we can equip TypeScript with the knowledge it needs to interpret the types that our functions are actually working with.

Using mappers in codegen 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 .

Adding mappers

Here's how we'll use mappers 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.
  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 type, as generated in types.ts. But we know now from the shape of our REST API responses that our Playlist type isn't exactly accurate when it comes to describing 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 instead.

spotify-api.ts
import { RESTDataSource } from "@apollo/datasource-rest";
import { PlaylistModel } 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}`);
}
}

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!

Key takeaways

  • We can use mappers in codegen to deal with differences in the shape of our backend objects and our types
  • 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.