---
title: 'Schema and Type System'
source: 'https://academia.sh/en/courses/api-design/schema-and-type-system'
course: 'Web API Design'
language: en
updated: '2026-08-19T05:19:30+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```js
// 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.

```js
// 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.

```js
// 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);
  }
}
```

```js
// 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.
