Baton

The guide

Honest data

Nullability as the schema says it, @required and @catch, field errors that survive caching, missing data that heals, enums and mapped scalars.

Nullability is what the schema says. Missing data is reported and healed, never invented silently. A field error survives caching and is visible where it occurred. Staleness is a phase a view can read. This chapter is the directives and the types that make those four sentences true, each with the fixture or the lens that shows it.

Types follow the schema#

A non-null field is a non-optional property; a nullable one is optional. That is the whole rule, and it means a view stops guarding what the server promises and stops trusting what it does not. The lens plate on any page shows it: title on GitHub's Issue is String! in the schema and a String in Swift; name on Rick and Morty's Character is String and reads as an optional.

The lens of RepositoryQuery, an operation's data, reads a record of type Query:

accessorreadsas
repositoryrepositorya lens of Repository, a Result, under @catch
  nameWithOwnernameWithOwnera scalar
  descriptiondescriptiona scalar, optional
  issuesissuesa lens of IssueConnection
    nodesnodesa lens of Issue, a list, optional
      idida scalar
      issueRowthe parent's recordthe lens of IssueRow_issue, optional
Plate 1. A lens with the three shapes this chapter is about: repository reads a Result under @catch; the row's spread reads as optional because the row requires an author; description is optional because the schema says so.

@required: a field the view cannot do without#

Some nulls the schema allows are nulls the product has no use for: a row for an issue whose author is gone has nothing to show. @required(action:) moves the null up to where the product wants to handle it:

  • NONE and LOG make the field non-optional and bubble the null to the lens boundary: the accessor that produces the lens produces nil instead, LOG logs the path through the environment's log, and a null that bubbles all the way to an operation's root fails the operation with the path of the first required field that was null.
  • THROW makes the field's accessor throw a required-field error, at the read.

RepositoryScreen.swift

import Baton
import SwiftUI

struct IssueRow: View {
    @Fragment("""
    fragment IssueRow_issue on Issue {
      number
      title
      author @required(action: LOG) { login }
    }
    """)
    var issue: IssueRow_issue

    var body: some View {
        Text("#\(issue.number) \(issue.title) by \(issue.author.login)")
    }
}

struct RepositoryScreen: View {
    @Query("""
    query RepositoryQuery($owner: String!, $name: String!) {
      repository(owner: $owner, name: $name) @catch {
        nameWithOwner
        description
        issues(first: 5, states: OPEN) {
          nodes { id ...IssueRow_issue }
        }
      }
    }
    """)
    var repository: RepositoryQuery

    var body: some View {
        switch repository.phase {
        case .ready(let data):
            switch data.repository {
            case .failure(let errors):
                Text("Could not load the repository: \(errors.description)")
            case .success(nil):
                Text("No such repository")
            case .success(let repo?):
                List {
                    Text(repo.nameWithOwner).font(.headline)
                    if let description = repo.description { Text(description) }
                    ForEach(repo.issues.nodes ?? .empty) { issue in
                        // An issue whose author is gone reads as no row.
                        if let row = issue.issueRow { IssueRow(issue: row) }
                    }
                }
            }
        case .loading:
            ProgressView()
        case .failed(let error):
            ErrorView(error) { repository.retry() }
        }
    }
}
Plate 2. The row requires its author; an issue without one reads as no row, and the screen's if let row skips it. The environment logs where.

@catch: an error the view shows#

A field error, a server's failure to resolve one field, is stored beside the field in the record, with its message, its path and its extensions, so a cached read sees the same error the network read saw. A plain read sees null; @catch reads a Result, success with the value or failure with the errors, so the view can say what went wrong:

switch data.repository {
case .failure(let errors): Text("Could not load the repository: \(errors.description)")
case .success(nil): Text("No such repository")
case .success(let repo?): RepositoryBody(repo)
}

@catch(to: NULL) keeps the optional type and reads errors as null, for a field the view would rather blank. @throwOnFieldError on a fragment or an operation is the strict policy: an uncaught error inside fails the read, or the operation, with the errors, and fields the schema marks @semanticNonNull read non-optional under it. An app that wants a server's onError behaviour states it once in baton.json; the compiler writes it into every operation, and the runtime sends it.

The server answers TestProfileQuery(id: "1") with tests/character-errors.json, and the environment commits it as a server batch.

tests/character-errors.json
{
  "data": {
    "character": {
      "id": "1",
      "name": "Rick Sanchez",
      "origin": {
        "id": "1",
        "name": "Earth (C-137)"
      },
      "status": "Alive",
      "image": null,
      "location": {
        "id": "20",
        "name": null,
        "dimension": "Replacement Dimension"
      },
      "gender": null,
      "species": "Human",
      "type": null
    }
  },
  "errors": [
    {
      "message": "image service unavailable",
      "path": [
        "character",
        "image"
      ],
      "locations": [
        {
          "line": 1,
          "column": 1
        }
      ]
    },
    {
      "message": "location name redacted",
      "path": [
        "character",
        "location",
        "name"
      ]
    },
    {
      "message": "gender is private",
      "path": [
        "character",
        "gender"
      ]
    }
  ]
}

Notified Character:1.gender Character:1.image Location:20.name

Plate 3. Field errors are data: a response that carries errors for three fields notifies those three slots, and a response that answers them notifies them again. A view reading through @catch sees the error, then the value.

A response with errors and no data at all is not a field error but a request error: the whole fetch fails. With nothing in the store the phase reads failed with the server's errors and a retry is one call away; with data already there, the data stays and the fetch carries the failure.

Missing data heals#

Missing data is the one case the types cannot express. It arises when a link is retargeted by another operation to a record fetched with fewer fields: a list fetched a location's name, a detail fetched its dimension, and a later response points the character's origin at a location the store knows only by name. Then a read of dimension finds no slot. Baton does not invent a value: the lens reads nil, the event is logged with the type and the field, the owning operation is marked stale and refetched once, and the view shows a blank for a moment and then the truth.

A view attaches a handle for TestConditions(id: "1", withOrigin: true, hideStatus: false) under storeOrNetwork, named rick; the transport answers its fetch with tests/conditions-included.json.

tests/conditions-included.json
{
  "data": {
    "character": {
      "name": "Rick Sanchez",
      "origin": {
        "name": "Earth (C-137)",
        "dimension": "Dimension C-137",
        "id": "1"
      },
      "species": "Human",
      "status": "Alive",
      "id": "1"
    }
  }
}

Logged fetchStarted {"operation":"TestConditions"} committed {"kind":"server","changed":1} fetchCompleted {"operation":"TestConditions"}

A lens readsand finds
rick · character.origin.dimensionDimension C-137
Plate 4. A read that finds Location.dimension missing logs it and refetches the owner; the refetch restores the value; a second miss after the heal's own refetch is logged as unexpected and healed no further.

A value the generated type cannot hold, a null in a non-null field or a string where an integer was promised, is logged as unexpected and reads as nil, or as the zero value where the type has no nil; a mapped scalar that does not convert never reads as a zero at all.

Enums#

A schema enum compiles to an enum of its own, with a case per value the build knows and one more carrying the text of a value it does not, so a value the schema gained after the build reads as itself rather than failing the read. Swift's case is unknown(String); Kotlin's is Undeclared(scalarText).

extension IssueState {
    var word: String {
        switch self {
        case .OPEN: "open"
        case .CLOSED: "closed"
        case .unknown(let text): text.lowercased()
        }
    }
}

Custom scalars#

A custom scalar is stored as its text, exactly as the server wrote it, and reads as a string; or customScalarTypes in baton.json maps it to a type of the app's, which in Swift Decimal, Date, URL and UUID already are and in Kotlin takes a converter object, and the text is converted at the read. Because a conversion can fail, the accessor is optional wherever the schema puts the field, unless @required or @throwOnFieldError says what a failure does; a text that does not convert is reported as unexpected, never read as a zero.

{
  "customScalarTypes": {
    "DateTime": { "swift": "Foundation.Date", "kotlin": { "type": "java.time.Instant", "converter": "app.DateTimes" } }
  }
}

Input objects#

An input object compiles to a value type with a field per field, typed as the schema types it, so a misspelled input field is a compile error where it was a server's error before. A field left unset is absent from the request, as GraphQL distinguishes absent from null.

try await addComment(input: AddCommentInput(body: body, subjectId: subjectID), connections: [connectionID])

Staleness is a phase#

A handle reads isStale beside its phase: true when the data is older than the operation's expiration or was marked stale by an invalidation, and the age survives a launch because the image keeps it. Stale data stays visible; a storeOrNetwork attach refetches it, and a revalidation on the app's return refetches whatever went stale meanwhile. Reads has the policies.

The principle behind it#

Honest data is the principle: why every client lies in small ways that compound, and the mechanisms, fixtures and events that keep this one from doing so.