Skip to content
academia.sh

Lesson 26 / 34

Schema and Type System

Defining object, scalar, enum, interface, and union types, parsing the query document, and the schema catching errors by validating the query before it runs.

Contents

Throughout the previous topic, the contract was built the same way every time: the server decided which resource it would give with which fields, and the client settled for what it was given. Partial response and field selection loosened this rigidity, but the server still drew the boundary.

The query-based approach reverses this relationship. The server defines a type system; the client decides which fields will come back, and the response takes the shape of the query. In exchange, the server asks for one guarantee: everything the client can ask for must already be defined by types.

Across this topic, a small GraphQL engine will be written for the loan service. This lesson builds the engine’s first two parts — the type system and the parser — and demonstrates its third: the schema validating the query before it runs.

Five Kinds of Types

A schema is a record made up of named types. Five kinds are enough.

  • Scalar is an indivisible value: an identifier, a string, a number, a date. It cannot take a subselection.
  • Enum is a fixed set of values. Like scalar, it is a leaf.
  • Object is a composite type with named fields. A subselection is required.
  • Interface declares the fields that more than one object type shares. Only the common fields can be selected through an interface; reading a type-specific field requires dropping down to the concrete type.
  • Union lets types with no common field return together. No field can be selected directly through a union.

The difference between interface and union is what the query is allowed to do. Both the loan record and the member record carry an identifier and a creation date; this commonality is an interface. A search result, on the other hand, returns either a book or a member, and the two share no common field; this is a union.

// schema.mjs — the loan service's type system
// Kind values: scalar, enum, object, interface, union. A field's type is written as a
// string: "Loan", "ID!", "[Item!]!" — ! means required, [] means list.

export const SCHEMA = {
  query: "Query",
  mutation: "Mutation",
  types: {
    ID:      { kind: "scalar" },
    String:  { kind: "scalar" },
    Int:     { kind: "scalar" },
    Date:    { kind: "scalar" },
    Status:  { kind: "enum", values: ["OPEN", "CLOSED", "OVERDUE"] },

    // Interface: declares the fields shared by more than one type.
    Record:  { kind: "interface", fields: { id: "ID!", createdAt: "Date!" } },

    Book:    { kind: "object", fields: { isbn: "ID!", title: "String!", author: "String!", copies: "Int!" } },
    Member:  { kind: "object", interfaces: ["Record"],
               fields: { id: "ID!", createdAt: "Date!", name: "String!",
                         loans: { type: "[Loan!]!", args: { status: "Status" } } } },
    Item:    { kind: "object", fields: { book: "Book!", branch: "String!" } },
    Loan:    { kind: "object", interfaces: ["Record"],
               fields: { id: "ID!", createdAt: "Date!", member: "Member!", items: "[Item!]!",
                         status: "Status!", returnDate: "Date" } },

    // Union: types with no common field returning together.
    SearchResult: { kind: "union", members: ["Book", "Member"] },

    Query:   { kind: "object", fields: {
                 loan: { type: "Loan", args: { id: "ID!" } },
                 loans: { type: "[Loan!]!", args: { status: "Status" } },
                 member: { type: "Member", args: { code: "ID!" } },
                 search: { type: "[SearchResult!]!", args: { term: "String!" } },
                 record: { type: "Record", args: { id: "ID!" } } } },
    Mutation: { kind: "object", fields: {
                 issueLoan: { type: "Loan", args: { member: "ID!", isbn: "ID!" } },
                 returnLoan:   { type: "Loan", args: { id: "ID!" } } } },
  },
};

// "[Item!]!" -> { name: "Item", list: true, required: true }
export function parseType(text) {
  let s = text, required = false, list = false;
  if (s.endsWith("!")) { required = true; s = s.slice(0, -1); }
  if (s.startsWith("[")) { list = true; s = s.slice(1, -1); if (s.endsWith("!")) s = s.slice(0, -1); }
  return { name: s, list, required };
}

export const fieldDef = (type, name) => {
  const t = type.fields?.[name];
  return t === undefined ? null : typeof t === "string" ? { type: t, args: {} } : { args: {}, ...t };
};

// The concrete types that can fall under an interface or a union
export const possibleTypes = (schema, name) => {
  const t = schema.types[name];
  if (t.kind === "union") return t.members;
  if (t.kind === "interface") return Object.entries(schema.types).filter(([, x]) => x.interfaces?.includes(name)).map(([a]) => a);
  return [name];
};

Query and Mutation have nothing special about them; they are ordinary object types. What makes them starting points is that their names are written in the schema’s query and mutation fields. The type system is a graph, and these two names mark the nodes where the graph is entered.

Parsing the Query

A query is text; it has to be turned into a tree before it can be validated. The parser below recognizes operation types, aliases, arguments, variables, and query fragments, called fragments for short in this course.

// parser.mjs — turns a GraphQL query document into a tree
// Supported: operation types, alias, argument, variable, fragment and inline fragment.

const TOKEN = /\s+|,|#[^\n]*|(\.\.\.|[{}():$=[\]!]|"(?:[^"\\]|\\.)*"|-?\d+(?:\.\d+)?|[_A-Za-z][_0-9A-Za-z]*)/g;

const tokenize = (text) => [...text.matchAll(TOKEN)].map((e) => e[1]).filter((s) => s !== undefined);

export function parse(text) {
  const s = tokenize(text);
  let i = 0;
  const peek = () => s[i];
  const take = (expected) => {
    if (expected !== undefined && s[i] !== expected) throw new Error(`expected "${expected}", got "${s[i] ?? "end of document"}"`);
    return s[i++];
  };

  const parseValue = () => {
    if (peek() === "$") { take("$"); return { kind: "variable", name: take() }; }
    if (peek() === "[") { take("["); const d = []; while (peek() !== "]") d.push(parseValue()); take("]"); return { kind: "literal", value: d }; }
    const t = take();
    if (t.startsWith('"')) return { kind: "literal", value: JSON.parse(t) };
    if (/^-?\d/.test(t)) return { kind: "literal", value: Number(t) };
    if (t === "true" || t === "false") return { kind: "literal", value: t === "true" };
    return { kind: "enum", value: t };                          // enum value
  };

  const typeName = () => {                                      // Loan! [Item!]! and the like
    let name;
    if (peek() === "[") { take("["); name = `[${typeName()}]`; take("]"); } else name = take();
    if (peek() === "!") { take("!"); name += "!"; }
    return name;
  };

  const selectionSet = () => {
    take("{");
    const selection = [];
    while (peek() !== "}") {
      if (peek() === "...") {
        take("...");
        if (peek() === "on") { take("on"); const type = take(); selection.push({ kind: "inline", type, selection: selectionSet() }); }
        else selection.push({ kind: "spread", name: take() });
        continue;
      }
      let name = take(), alias = null;
      if (peek() === ":") { take(":"); alias = name; name = take(); }
      const args = {};
      if (peek() === "(") { take("("); while (peek() !== ")") { const a = take(); take(":"); args[a] = parseValue(); } take(")"); }
      const sub = peek() === "{" ? selectionSet() : null;
      selection.push({ kind: "field", name, alias: alias ?? name, args, selection: sub });
    }
    take("}");
    return selection;
  };

  const operations = [], fragments = {};
  while (i < s.length) {
    if (peek() === "fragment") {
      take("fragment"); const name = take(); take("on"); const type = take();
      fragments[name] = { name, type, selection: selectionSet() };
      continue;
    }
    let type = "query", name = null;
    if (["query", "mutation", "subscription"].includes(peek())) { type = take(); if (peek() !== "(" && peek() !== "{") name = take(); }
    const variables = [];
    if (peek() === "(") {
      take("(");
      while (peek() !== ")") {
        take("$"); const vname = take(); take(":"); const vtype = typeName();
        let defaultValue = undefined;
        if (peek() === "=") { take("="); defaultValue = parseValue().value; }
        variables.push({ name: vname, type: vtype, defaultValue });
      }
      take(")");
    }
    operations.push({ type, name, variables, selection: selectionSet() });
  }
  return { operations, fragments };
}

The one thing the parser’s tree does not know is meaning. If a field called overdueFine is selected, the parser places it into the tree without any trouble; it is the type system that will say no such field exists.

The Schema Validating the Query

The validator walks the selection set together with the schema’s types, and at every step looks for a match.

// validate.mjs — checks the query against the schema; finds errors before running it
import { parseType, fieldDef, possibleTypes } from "./schema.mjs";

const LEAF = new Set(["scalar", "enum"]);

export function validate(schema, document) {
  const errors = [];
  for (const op of document.operations) {
    const root = op.type === "mutation" ? schema.mutation : schema.query;
    const declared = new Set(op.variables.map((d) => d.name));
    validateSelection(schema, document, root, op.selection, root, declared, errors);
  }
  return errors;
}

function validateSelection(schema, document, typeName, selection, path, declared, errors) {
  const type = schema.types[typeName];
  for (const s of selection) {
    if (s.kind === "spread") {
      const fragment = document.fragments[s.name];
      if (!fragment) { errors.push(`${path}: fragment "${s.name}" is not defined`); continue; }
      if (!possibleTypes(schema, typeName).includes(fragment.type) && fragment.type !== typeName)
        errors.push(`${path}: fragment "${s.name}" is for ${fragment.type}, the type here is ${typeName}`);
      else validateSelection(schema, document, fragment.type, fragment.selection, `${path}...${s.name}`, declared, errors);
      continue;
    }
    if (s.kind === "inline") {
      if (!possibleTypes(schema, typeName).includes(s.type))
        errors.push(`${path}: ${s.type} cannot fall under ${typeName} (possible types: ${possibleTypes(schema, typeName).join(", ")})`);
      else validateSelection(schema, document, s.type, s.selection, `${path}...${s.type}`, declared, errors);
      continue;
    }

    // A union type cannot select a field directly; a concrete type must be reached first.
    if (type.kind === "union") { errors.push(`${path}: ${typeName} is a union, field "${s.name}" cannot be selected directly`); continue; }

    const def = fieldDef(type, s.name);
    if (!def) { errors.push(`${path}: type ${typeName} has no field "${s.name}"`); continue; }

    for (const a of Object.keys(s.args)) {
      if (!(a in def.args)) errors.push(`${path}.${s.name}: argument "${a}" is not defined`);
    }
    for (const d of Object.values(s.args)) {
      if (d.kind === "variable" && !declared.has(d.name)) errors.push(`${path}.${s.name}: variable $${d.name} is not declared`);
    }
    for (const [a, t] of Object.entries(def.args)) {
      if (t.endsWith("!") && !(a in s.args)) errors.push(`${path}.${s.name}: required argument "${a}" is missing`);
    }

    const fieldType = schema.types[parseType(def.type).name];
    const isLeaf = LEAF.has(fieldType.kind);
    if (isLeaf && s.selection) errors.push(`${path}.${s.name}: ${parseType(def.type).name} is a leaf, it cannot take a subselection`);
    else if (!isLeaf && !s.selection) errors.push(`${path}.${s.name}: ${parseType(def.type).name} is composite, a subselection is required`);
    else if (!isLeaf) validateSelection(schema, document, parseType(def.type).name, s.selection, `${path}.${s.name}`, declared, errors);
  }
}
// run.mjs — validates one valid and six invalid queries against the schema
import { parse } from "./parser.mjs";
import { validate } from "./validate.mjs";
import { SCHEMA } from "./schema.mjs";

const QUERIES = {
  "valid query": `
    query LoanScreen($member: ID!) {
      member(code: $member) { name loans(status: OPEN) { ...Summary } }
      search(term: "algorithm") { ... on Book { title } ... on Member { name } }
      record(id: "O-1") { id createdAt }
    }
    fragment Summary on Loan { id status items { book { title } } }`,

  "nonexistent field": `{ loan(id: "O-1") { id overdueFine } }`,
  "subselection on a leaf": `{ loan(id: "O-1") { status { name } } }`,
  "no subselection on a composite": `{ loan(id: "O-1") { id member } }`,
  "direct field from a union": `{ search(term: "x") { title } }`,
  "wrong fragment type": `{ loan(id: "O-1") { ...BookSummary } } fragment BookSummary on Book { title }`,
  "missing argument and undeclared variable": `{ loan { id } member(code: $k) { name } }`,
};

for (const [name, text] of Object.entries(QUERIES)) {
  const errors = validate(SCHEMA, parse(text));
  console.log(`\n${name}  (${errors.length} error${errors.length === 1 ? "" : "s"})`);
  for (const e of errors) console.log(`  ${e}`);
}
valid query  (0 errors)

nonexistent field  (1 error)
  Query.loan: type Loan has no field "overdueFine"

subselection on a leaf  (1 error)
  Query.loan.status: Status is a leaf, it cannot take a subselection

no subselection on a composite  (1 error)
  Query.loan.member: Member is composite, a subselection is required

direct field from a union  (1 error)
  Query.search: SearchResult is a union, field "title" cannot be selected directly

wrong fragment type  (1 error)
  Query.loan: fragment "BookSummary" is for Book, the type here is Loan

missing argument and undeclared variable  (2 errors)
  Query.loan: required argument "id" is missing
  Query.member: variable $k is not declared

One of the seven queries passed, six were rejected, and none of them ran. Every rejection was made using only information from the schema; the data source was never consulted, and not a single resolver was called.

This is a structurally different point from resource-based design. There, what an endpoint accepts is validated at run time, and the consumer learns which fields exist from documentation. Here, the same information lives in the schema and can be validated before the query is even sent; the machine-readable definition that had to be written separately in the Machine-Readable Documentation lesson is the type system itself.

What the Type System Gives and What It Asks For

The interface and union rows are where the type system does the most work. The record(id:) field returns an interface; the query can select only the id and createdAt fields from it, because those are the fields the interface declares. A query that wants to read the status field specific to a loan record has to drop down to the concrete type by writing ... on Loan. In a union, there is no common field at all; no branch can read anything without declaring its own type.

What this strictness buys is that the response’s shape can be derived from the query. The client knows the shape of the body that will come back just by looking at the selection set it sent; the server has no need to publish a separate response schema.

The price it asks for is that the schema be complete. Nothing undefined in the schema can be asked for; there is no escape hatch such as returning the raw data as is for now. Every field, every argument, and every type relationship of the loan service has to be written down.

Summary

  • A schema is built from five kinds of types: scalar and enum are leaves, object is composite, interface declares common fields, and union returns types with no common field together.
  • Only common fields are read through an interface; no field can be read directly through a union — in both cases, reading a type-specific field requires dropping down to the concrete type.
  • Query and Mutation are ordinary object types; what makes them starting points is that their names are written into the schema as entry nodes.
  • The parser turns the query into a tree but does not know its meaning; it is the type system that says whether a field exists.
  • Validation against the schema happens without the query running and without a single resolver being called; a nonexistent field, a subselection on a leaf, a missing selection on a composite, a wrong fragment type, and a missing argument are all caught at this stage.
  • What the type system buys is that the response’s shape can be derived from the query; what it costs is that nothing undefined in the schema can be asked for.

Next Step

The validated query has not run yet; the schema says what can be asked, not where the response will come from. And only one operation type has been discussed so far. Issuing a loan is not a read but a write, and it asks for a different guarantee about ordering on the server; waiting for a book’s status to change is neither a read nor a write — it produces more than one response over time. The next lesson defines the three operation types, writes the engine’s first version, and shows where the three diverge in their execution rules.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close