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:
- Type the field as
IDorStringif it is only ever an identifier. - Define a custom
LongorBigIntscalar with an agreed string representation. - Keep
Intonly 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:
serializeruns 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.parseValuehandles values that arrive as JSON in thevariablesmap, wheretypeofchecks are possible because the value is already a parsed JavaScript value.parseLiteralreceives an AST node, not a JavaScript value:42is anIntnode whosevalueis the string"42". Returningnullmarks 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
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?
2. Why is ID not simply String under another name?
3. Which value belongs in a custom scalar rather than String?
4. What does parseLiteral receive that parseValue does not?
Flashcards
Question
The five built-in scalars
Click to reveal answer
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
Click to reveal answer
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
Click to reveal answer
Answer
Output coercion rejects the value, the field errors, and null propagation applies. No rounding or widening occurs.
Question
Three custom scalar functions
Click to reveal answer
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
Click to reveal answer
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.