Skip to content
beginner Phase 2 · Schema Foundations: Types and the Query Root

Built-in Scalar Types

The built-in scalars Int, Float, String, Boolean, and ID and how they serialize.

30m
1 problems
Topic Progress 0%

Five Types, Precise Coercion Rules

Five Types, Precise Coercion Rules

GraphQL ships with exactly five scalar types. Scalars are leaves: they hold a value, they have no fields of their own, and in a query they must never be followed by a selection set.

Scalar Input accepted Output Contract
Int integer values JSON number signed 32-bit: -2147483648 to 2147483647
Float number values JSON number double precision
String string values JSON string valid UTF-8 text
Boolean true / false true / false no other spellings
ID string or integer JSON string opaque record identifier

Two rules follow directly from the table.

type Product {
  id: ID!
  title: String!
  priceCents: Int!
  rating: Float
  inStock: Boolean!
}

Leaf fields take no sub-selection. Querying product { title { length } } fails validation: title is a String, and scalars have no fields. Conversely, an object field with no sub-selection also fails validation, because it has no value of its own to return.

Coercion is the type system's job, not JSON's. JSON does not distinguish 42 from 42.0, so the GraphQL layer decides: a float with a fractional part cannot complete an Int field, and a value outside the 32-bit range cannot be represented at all. When coercion fails on output, the resolver's value does not reach the client as a rounded number; it becomes a field error.

ID deserves special attention because its two directions differ. On output it always serializes as a string, even when your database stores a numeric key. On input it accepts either a string or an integer, so clients may send "42" or 42 and both coerce to the same identity. That flexibility is the point: an ID field says "this is an opaque handle," which is different from "this is a number you may do arithmetic on." Use Int when the value participates in math, ID when it only identifies something.

Where the Built-in Scalars Break

Where the Built-in Scalars Break

Every production schema eventually hits a wall with the five built-ins. Knowing the three common walls saves a migration later.

The 32-bit ceiling. Int stops at 2147483647. View counts exceed it, timestamps in milliseconds (1.7356896e12) exceed it by five orders of magnitude, and snowflake ids are 64-bit integers. Returning such a value from an Int! field does not silently truncate: coercion rejects it and the field errors. Options, in order of preference:

  1. Type the field as ID or String if it is only ever an identifier.
  2. Define a custom Long or BigInt scalar with an agreed string representation.
  3. Keep Int only where the range genuinely fits.

Money in Float. Floating point cannot represent 0.1 exactly, so sums of prices drift: 0.1 + 0.2 !== 0.3. Product teams notice this during reconciliation, not during development. Either store and expose integer minor units (priceCents: Int!, currency: String!) or define a Money/Decimal custom scalar whose serialization is exact.

Temporal values as String. createdAt: String works until two teams disagree about whether it holds an ISO instant, a local date, or epoch seconds. A DateTime custom scalar converts that disagreement into a contract, especially when it advertises its format with @specifiedBy, a directive introduced in the October 2021 edition that points a scalar at a published specification URL so tools and humans know exactly what the values mean.

The judgment call is about who pays. A String field costs nothing today and moves the validation into every consumer. A custom scalar costs one implementation and moves validation back to the server, which is where the rest of the type system already lives.

Writing a Custom Scalar

Writing a Custom Scalar

A custom scalar is an object with three functions, and each one guards a different direction of travel.

import { GraphQLScalarType, GraphQLError, Kind } from 'graphql';

const NonNegativeInt = new GraphQLScalarType({
  name: 'NonNegativeInt',
  description: 'Integer from 0 to 2147483647',

  // output: value returned by a resolver -> value sent to the client
  serialize(value) {
    const n = Number(value);
    if (!Number.isInteger(n) || n < 0 || n > 2147483647) {
      throw new GraphQLError('Cannot serialize ' + String(value) + ' as NonNegativeInt');
    }
    return n;
  },

  // input: JSON value from the variables map -> value handed to the resolver
  parseValue(value) {
    if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
      throw new GraphQLError('NonNegativeInt must be an integer >= 0');
    }
    return value;
  },

  // input: literal written inside the query text -> value handed to the resolver
  parseLiteral(ast) {
    if (ast.kind !== Kind.INT) return null;
    const n = parseInt(ast.value, 10);
    return n >= 0 && n <= 2147483647 ? n : null;
  },
});

Read the three roles carefully, because they fail in different places:

  • serialize runs on the way out, so a database value that violates the contract surfaces as an execution-time field error rather than as corrupt data on the wire.
  • parseValue handles values that arrive as JSON in the variables map, where typeof checks are possible because the value is already a parsed JavaScript value.
  • parseLiteral receives an AST node, not a JavaScript value: 42 is an Int node whose value is the string "42". Returning null marks the literal invalid, which fails validation before execution.

Register the scalar in the schema like any other type, and the field declaration reads normally: quantity: NonNegativeInt!. Two integration notes: code generators emit any (or unknown) for custom scalars unless you map them to a TypeScript type, see TypeScript code generation; and if the semantics are published, attach @specifiedBy(url: "...") so tooling can look them up.

Interview angle: "How would you add a DateTime type?" Name all three functions, state where parsing happens (server side, on input) and where formatting happens (server side, on output), and mention that invalid input fails before execution while invalid stored data fails during execution.

Practice Problems

0 / 1 solved
Implement a NonNegativeInt Custom Scalar
Custom scalar

Add a NonNegativeInt scalar to a graphql-js server. It must accept integers from 0 to 2147483647 on input from both variables and inline literals, reject negatives, fractional values, and out-of-range values, and reject stored values outside the range on output. Show the scalar definition and the schema declaration that uses it, and state which failures occur before execution and which during.

Example:

Input: query ($n: NonNegativeInt!) { ... } with variables {"n": -5}

Output: Variable coercion error before execution; the request never reaches a resolver

parseValue runs during variable coercion, which sits after validation and before execution, so an invalid variable is a request-level failure.

Optimal Solution — O(1) per coercion time, O(1) space

Give each direction its own guard: parseValue type-checks the JSON value, parseLiteral inspects the AST node kind and parses its string digits, and serialize validates what the resolver produced. Throw for values that arrive as JSON, return null for invalid literals.

import { GraphQLScalarType, GraphQLError, Kind } from 'graphql';

const MAX = 2147483647;

const NonNegativeInt = new GraphQLScalarType({
  name: 'NonNegativeInt',
  serialize(value) {
    const n = Number(value);
    if (!Number.isInteger(n) || n < 0 || n > MAX) {
      throw new GraphQLError('Cannot serialize ' + String(value) + ' as NonNegativeInt');
    }
    return n;
  },
  parseValue(value) {
    if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > MAX) {
      throw new GraphQLError('NonNegativeInt must be an integer between 0 and ' + MAX);
    }
    return value;
  },
  parseLiteral(ast) {
    if (ast.kind !== Kind.INT) return null;
    const n = parseInt(ast.value, 10);
    return n >= 0 && n <= MAX ? n : null;
  },
});

// schema usage
// type Cart { quantity: NonNegativeInt! }

Edge Cases:

  • A Float literal such as 2.5 has kind FLOAT, so parseLiteral must reject it by checking Kind.INT
  • Variables sent as strings ("5") are rejected: NonNegativeInt does not inherit ID-style string input
  • Values above 2147483647 are valid integers in JavaScript but out of range for this scalar

Quiz

1. A resolver returns 1735689600000 (epoch milliseconds) for a field declared Int!. What happens?

Question 1 options

2. Why is ID not simply String under another name?

Question 2 options

3. Which value belongs in a custom scalar rather than String?

Question 3 options

4. What does parseLiteral receive that parseValue does not?

Question 4 options

Flashcards

Question

The five built-in scalars

Answer

Int (signed 32-bit), Float (double precision), String (UTF-8 text), Boolean (true/false), ID (string or int in, string out).

Question

ID vs String

Answer

ID: input accepts string or integer, output is always string; marks an opaque identifier. String: text only, and text may be operated on.

Question

What happens when an Int field exceeds 32 bits

Answer

Output coercion rejects the value, the field errors, and null propagation applies. No rounding or widening occurs.

Question

Three custom scalar functions

Answer

serialize: output value to JSON. parseValue: variables JSON to resolver value. parseLiteral: query AST node to resolver value. First two throw GraphQLError, parseLiteral returns null for invalid literals.

Question

@specifiedBy

Answer

Directive from the October 2021 edition that points a custom scalar at a published spec URL so tools and consumers know the value semantics.

Revision Notes

Key Takeaways

  • 1. Scalars are leaves: no sub-selection in queries, and their coercion rules are stricter than JSONs
  • 2. Int is 32-bit, money should not be Float, and identifiers should be ID
  • 3. Custom scalars move format validation from every consumer back to the server
  • 4. serialize guards output while parseValue and parseLiteral guard two different input paths

Interview Tips

  • • Be able to name the range of Int and what the server does when a resolver exceeds it
  • • Walk all three custom scalar functions and say which failures happen before execution and which during

Cheat Sheet

Built-in scalars

Scalar Range / meaning
Int signed 32-bit: -2147483648..2147483647
Float double precision
String UTF-8 text
Boolean true / false
ID string or int input, string output

Rules: scalar fields take no sub-selection; output coercion failure = field error (no rounding).

Money: use minor units (priceCents: Int!) or a Decimal scalar, never Float.

Custom scalar (graphql-js): serialize (output, throws), parseValue (variables, throws), parseLiteral (AST, returns null when invalid); add @specifiedBy(url) for published semantics.

Continue Learning