<img height="1" width="1" style="display:none" src="https://www.facebook.com/tr?id=1063935717132479&amp;ev=PageView&amp;noscript=1 https://www.facebook.com/tr?id=1063935717132479&amp;ev=PageView&amp;noscript=1 "> Schemas and Resolvers

Schemas and Resolvers

Overview

In this section, we will:

  • Read a GraphQL schema
  • Learn what resolvers are and how they receive arguments
  • Understand default resolvers, and add a calculated field that needs its own resolver
  • Add a riskTier argument to the policies query

Objective 1: Read the schema

The schema is the contract

The schema describes everything a client can ask for and what comes back. Ours lives in services/policies/src/schema.graphql. Here’s part of it, with the descriptions left out:

type Policy {
  id: ID!
  policyNumber: String!
  type: PolicyType!
  monthlyPremium: Float!
  effectiveDate: String!
  riskTier: RiskTier
  policyholder: Policyholder!
}

enum PolicyType {
  AUTO
  HOME
  LIFE
  RENTERS
}

enum RiskTier {
  LOW
  MEDIUM
  HIGH
}

type Query {
  policies(type: PolicyType): [Policy!]!
  policy(id: ID!): Policy
  policyholders: [Policyholder!]!
  policyholder(id: ID!): Policyholder
}

A few things to notice:

  • Types and fields: Policy is an object type with fields. ID, String, Float, Int, and Boolean are built-in scalar types.
  • ! means non-null: policyNumber: String! always has a value. riskTier: RiskTier may be null.
  • [Policy!]! is a list: a non-null list of non-null policies.
  • Enums: PolicyType and RiskTier limit a value to a fixed set.
  • Query is special: its fields are the entry points for reading data. policies(type: PolicyType) declares the argument you used in the Writing Queries section.
  • Descriptions: in the real file, many types and fields have a string just above them, like "Monthly premium in US dollars" above monthlyPremium. That string is the field’s description. Apollo Sandbox shows it in the Documentation panel. A description that needs more than one line uses three quotes on each side, """like this""".

Lists and nullability

A list type has two places to put !: after the item type, inside the brackets, and after the brackets. The first says whether an item can be null. The second says whether the list itself can be null. That gives four combinations:

Schema List can be null? Items can be null? Valid responses Invalid responses
[String] Yes Yes null
[]
["A", null, "B"]
None
[String!] Yes No null
[]
["A", "B"]
["A", null, "B"]
[String]! No Yes []
["A", null, "B"]
null
[String!]! No No []
["A", "B"]
null
["A", null, "B"]

In plain English:

  • [String]: you might get no list, and any item in it might be missing.
  • [String!]: you might get no list, but if you get one, every item is there.
  • [String]!: you always get a list, but any item in it might be missing.
  • [String!]!: you always get a list, and every item is there. “Nothing found” is an empty list, [].

Use [Item!]! unless you have a reason not to. It’s the easiest shape for clients. They can loop over the result without checking for null first, and an empty list means there’s nothing to show. That’s why policies and policyholders in our schema return [Policy!]! and [Policyholder!]!.

Objective 2: Understand resolvers

A resolver supplies one field’s data

A resolver is a function that returns the data for one field. Ours live in services/policies/src/resolvers.ts:

export const resolvers = {
  Query: {
    policies: (_: unknown, args: { type?: PolicyType }) =>
      policies.filter((p) => {
        if (args.type && p.type !== args.type) return false;
        return true;
      }),
    // ...
  },
  Policy: {
    policyholder: (policy: Policy) =>
      policyholders.find((ph) => ph.id === policy.policyholderId),
  },
};

Apollo matches resolvers to the schema by name: resolvers.Query.policies answers the policies field on type Query.

Every resolver receives four arguments: (parent, args, contextValue, info).

  • parent is the object that owns the field. Query fields have no parent, so policies ignores it (_).
  • args holds the query’s arguments. policies(type: AUTO) calls the resolver with { type: "AUTO" }.
  • contextValue is shared by every resolver in a request. It’s typically where the logged-in user or a database connection lives.
  • info describes the query itself.

Reading the policies resolver

The resolver’s args match the field’s arguments in the schema:

  • policies(type: PolicyType) in the schema declares one argument, type. It has no !, so a query can leave it out.
  • args: { type?: PolicyType } in the resolver is that same argument. When a query leaves type out, args.type is empty.
  • The if line leaves out any policy that doesn’t match args.type, but only when a type was passed.

So policies(type: AUTO) returns only the auto policies, and policies with no arguments returns all of them.

The if line is a filter. Each argument you want to filter by gets its own args entry and its own if line, in the same shape.

Resolvers chain together

In policies { policyholder { name } }:

  1. Query.policies runs once and returns a list of policies.
  2. Policy.policyholder runs once for each policy, receiving that policy as parent. That’s how it can read policy.policyholderId.

If a query doesn’t ask for policyholder, Policy.policyholder never runs.

For policies { policyholder { name } }, Query.policies runs once and returns p1 to p5. Policy.policyholder then runs once per policy with that policy as parent, returning ph1, ph1, ph2, ph3, ph3. A default resolver reads each name.
How the resolvers run for policies { policyholder { name } }.

Default resolvers

Look back at the resolvers above. There’s a resolver for Policy.policyholder, but none for Policy.policyNumber, Policy.type, or any other Policy field. Yet all of them work.

When a field has no resolver, GraphQL uses a default resolver. It returns the property with the same name from the parent object. In policies { policyNumber }, Query.policies returns objects from services/policies/src/data.ts like this one:

{ id: "p1", policyNumber: "AUTO-100001", type: "AUTO", monthlyPremium: 142.5, /* ... */ policyholderId: "ph1" }

For each policy, GraphQL resolves policyNumber by reading policy.policyNumber. You get this for free whenever the schema’s field names match your data’s property names.

The default resolver only reads properties. It can’t compute anything. Two things follow from that:

  • Data can have properties the schema doesn’t expose. policyholderId is on every policy object, but it isn’t a field on type Policy, so clients can’t ask for it. The Policy.policyholder resolver uses it behind the scenes.
  • A field with no matching property comes back as null. If the schema declares a field that isn’t in the data and has no resolver, the default resolver finds nothing. For a non-null field (!), that’s an error.
Policy fields matched to the properties of p1. Fields with matching names use the default resolver. policyholder uses a resolver you wrote, which reads policyholderId. policyholderId isn't in the schema, so clients can't ask for it. annualPremium has no property and no resolver, so it returns null, which is an error.
Where each Policy field gets its value for p1.

When a field’s value has to be looked up or calculated, write a resolver for it, the way Policy.policyholder looks up a policyholder from policyholderId.

Exercise

Billing wants to show each policy’s yearly cost, which is its monthlyPremium times 12. We’ll add this as an annualPremium field. The data doesn’t have an annualPremium property, so the server will calculate it.

This exercise has two parts. The first one breaks things on purpose.

Part 1: Add the field to the schema only.

✏️ In services/policies/src/schema.graphql, add a required annualPremium field to the Policy type. It’s a dollar amount, like monthlyPremium, so give it the same kind of type. Don’t add a resolver yet.

The server restarts automatically every time you save a file.

✏️ Run this query:

{
  policies {
    policyNumber
    monthlyPremium
    annualPremium
  }
}

The query fails. The response (trimmed for readability) is:

{
  "errors": [{ "message": "Cannot return null for non-nullable field Policy.annualPremium." }],
  "data": null
}

The schema accepted the new field, so the request passed validation. But no resolver was written for annualPremium, so the default resolver ran. It looked for policy.annualPremium, found nothing, and returned null, which breaks the !.

Part 2: Add the resolver.

✏️ In services/policies/src/resolvers.ts, add a resolver so annualPremium returns the policy’s yearly cost.

Hint: Look at how Policy.policyholder gets the policy it’s resolving a field for.

✏️ Run the query from Part 1 again. Your response should be:

{
  "data": {
    "policies": [
      { "policyNumber": "AUTO-100001", "monthlyPremium": 142.5, "annualPremium": 1710 },
      { "policyNumber": "HOME-100002", "monthlyPremium": 98, "annualPremium": 1176 },
      { "policyNumber": "AUTO-100003", "monthlyPremium": 210.75, "annualPremium": 2529 },
      { "policyNumber": "LIFE-100004", "monthlyPremium": 45, "annualPremium": 540 },
      { "policyNumber": "RENTERS-100005", "monthlyPremium": 18.25, "annualPremium": 219 }
    ]
  }
}

Solution

Click to see the solution

✏️ In services/policies/src/schema.graphql, add the field to Policy:

type Policy {
  id: ID!
  policyNumber: String!
  type: PolicyType!
  monthlyPremium: Float!
  annualPremium: Float!
  effectiveDate: String!
  riskTier: RiskTier
  policyholder: Policyholder!
}

✏️ In services/policies/src/resolvers.ts, add an annualPremium resolver next to policyholder:

  Policy: {
    policyholder: (policy: Policy) => policyholders.find((ph) => ph.id === policy.policyholderId),
    annualPremium: (policy: Policy) => policy.monthlyPremium * 12,
  },

Like policyholder, annualPremium receives the policy as parent. Every other Policy field still uses the default resolver.

The resolver runs only when a query asks for annualPremium, so the calculation costs nothing for queries that don’t use it.

Objective 3: Add a query argument

Exercise

Let’s make the query that failed in the Writing Queries section work:

{
  policies(riskTier: HIGH) {
    policyNumber
    riskTier
  }
}

✏️ Update services/policies/src/schema.graphql and services/policies/src/resolvers.ts so that policies can be filtered by riskTier, type, both, or neither.

Hint: Declaring an argument in the schema only gets it into args. The resolver still has to use it.

✏️ Run the query above. Your response should be:

{ "data": { "policies": [{ "policyNumber": "AUTO-100003", "riskTier": "HIGH" }] } }

✏️ Combine both filters:

{
  policies(type: AUTO, riskTier: MEDIUM) {
    policyNumber
    riskTier
  }
}

Your response should be:

{ "data": { "policies": [{ "policyNumber": "AUTO-100001", "riskTier": "MEDIUM" }] } }

Verify

✏️ Run policies with no arguments:

{
  policies {
    policyNumber
  }
}

It still returns all five policies.

Solution

Click to see the solution

✏️ Update the policies field in services/policies/src/schema.graphql:

type Query {
  policies(type: PolicyType, riskTier: RiskTier): [Policy!]!
  policy(id: ID!): Policy
  policyholders: [Policyholder!]!
  policyholder(id: ID!): Policyholder
}

✏️ In services/policies/src/resolvers.ts, update the policies resolver:

    policies: (_: unknown, args: { type?: PolicyType; riskTier?: RiskTier }) =>
      policies.filter((p) => {
        // A type was requested, and this policy isn't that type
        if (args.type && p.type !== args.type) return false;
        // A risk tier was requested, and this policy isn't that tier
        if (args.riskTier && p.riskTier !== args.riskTier) return false;
        // Passed every filter that was requested
        return true;
      }),

Two things changed:

  1. riskTier?: RiskTier was added to args, so the resolver knows about the new argument. RiskTier is already imported at the top of the file.
  2. A second if line was added. It follows the same shape as the type line: “if a risk tier was requested, and this policy isn’t that tier, leave it out.”

Each filter only applies when its argument was provided, so type and riskTier can be combined or left out.

Next steps

Next, we’ll change data with mutations.