03 · The Type System: Scalars, Objects, Lists, Non-Null & Enums¶
Everything a GraphQL server can return has a declared type, and every value is checked against that type at runtime — on the way in (arguments and variables) and on the way out (resolver results). Knowing exactly how strict each direction is saves a lot of confusion later, so this lesson tests the rules rather than just listing them.
The kinds of type¶
| Kind | SDL keyword | What it is |
|---|---|---|
| Scalar | scalar (5 built in) |
A leaf value: Int, Float, String, Boolean, ID |
| Object | type |
A named set of fields, each with its own type |
| Enum | enum |
A leaf with a fixed set of allowed names |
| Input object | input |
A set of fields used only as argument values (lesson 06) |
| Interface | interface |
A set of fields that several object types share (Level 2 · 02) |
| Union | union |
"One of these object types" (Level 2 · 02) |
On top of those, two wrapping types modify any type: [T] (a list of T) and T!
(non-null T).
Selection rules follow from the kinds. Leaf types — scalars and enums — must not have
a sub-selection: { title { x } } is invalid if title is a String. Composite types —
objects, interfaces, unions — must have one: { book } on its own is invalid, because
GraphQL never returns "the whole object". You always say which fields.
The five built-in scalars¶
| Scalar | Meaning | Serialized as |
|---|---|---|
Int |
Signed 32-bit integer (−2,147,483,648 to 2,147,483,647) | JSON number |
Float |
Double-precision floating point | JSON number |
String |
UTF-8 text | JSON string |
Boolean |
true / false |
JSON boolean |
ID |
An opaque identifier; may be a string or integer on input | JSON string |
Two of these trip people up. Int is 32-bit, so a database bigint or a millisecond
timestamp does not fit — use a String, a Float or a custom scalar
(Level 2 · 03). ID signals "don't do arithmetic on this,
it's an identifier" and always comes back as a string even if your resolver returned a number.
Testing the coercion rules¶
The script below gives each scalar resolver deliberately "wrong" values and sends arguments
of the wrong type. Install @graphql-tools/schema if you skipped it in lesson 02.
import { graphql } from "graphql";
import { makeExecutableSchema } from "@graphql-tools/schema";
const typeDefs = `
enum Format { HARDCOVER PAPERBACK EBOOK }
type Query {
intOut(v: String!): Int
floatOut: Float
strOut: String
boolOut: Boolean
idOut: ID
format: Format
badFormat: Format
a: [String]
b: [String!]
c: [String]!
d: [String!]!
echoInt(n: Int!): Int
echoFormat(f: Format!): String
}
`;
const withNull = ["x", null, "z"];
const resolvers = {
Format: { HARDCOVER: "hc", PAPERBACK: "pb", EBOOK: "eb" },
Query: {
intOut: (_, { v }) =>
v === "num" ? 12 : v === "str" ? "12" : v === "float" ? 12.5
: v === "big" ? 2 ** 31 : v === "bool" ? true : "abc",
floatOut: () => "3.14",
strOut: () => 42,
boolOut: () => 0,
idOut: () => 7,
format: () => "pb",
badFormat: () => "PAPERBACK",
a: () => withNull, b: () => withNull, c: () => withNull, d: () => withNull,
echoInt: (_, { n }) => n,
echoFormat: (_, { f }) => `internal value: ${JSON.stringify(f)}`,
},
};
const schema = makeExecutableSchema({ typeDefs, resolvers });
const run = async (source, variableValues) => {
const r = await graphql({ schema, source, variableValues });
console.log(source.trim(), variableValues ? JSON.stringify(variableValues) : "");
console.log(" ", JSON.stringify(r.data),
r.errors ? r.errors.map((e) => `${e.path ?? ""}: ${e.message}`) : "");
};
for (const v of ["num", "str", "float", "big", "bool", "abc"]) await run(`{ intOut(v: "${v}") }`);
await run(`{ floatOut strOut idOut }`);
await run(`{ boolOut }`);
await run(`{ format }`);
await run(`{ badFormat }`);
await run(`{ a }`); await run(`{ b }`); await run(`{ c }`); await run(`{ d }`);
await run(`{ echoInt(n: 3000000000) }`);
await run(`{ echoInt(n: "5") }`);
await run(`query($n: Int!) { echoInt(n: $n) }`, { n: "5" });
await run(`query($n: Int!) { echoInt(n: $n) }`, { n: 5.0 });
await run(`{ echoFormat(f: EBOOK) }`);
await run(`{ echoFormat(f: "EBOOK") }`);
Output coercion: lenient where it's safe¶
{ intOut(v: "num") } {"intOut":12}
{ intOut(v: "str") } {"intOut":12}
{ intOut(v: "float") } {"intOut":null} intOut: Int cannot represent non-integer value: 12.5
{ intOut(v: "big") } {"intOut":null} intOut: Int cannot represent non 32-bit signed integer value: 2147483648
{ intOut(v: "bool") } {"intOut":1}
{ intOut(v: "abc") } {"intOut":null} intOut: Int cannot represent non-integer value: "abc"
{ floatOut strOut idOut } {"floatOut":3.14,"strOut":"42","idOut":"7"}
{ boolOut } {"boolOut":false}
(Output reformatted onto one line per query; the values and messages are exactly as
printed by graphql 16.14.2.)
When serializing a resolver's result, graphql-js converts values that have an unambiguous
meaning in the target type: the string "12" becomes the integer 12, true becomes 1,
the number 42 becomes the string "42", 0 becomes false. It refuses anything that
would lose information — 12.5 as an Int, a value outside 32 bits, "abc" as a number —
and reports a field error: that field becomes null and an error is added, but the rest
of the response survives.
Enums: the name is public, the value is internal¶
{ format } {"format":"PAPERBACK"}
{ badFormat } {"badFormat":null} badFormat: Enum "Format" cannot represent value: "PAPERBACK"
{ echoFormat(f: EBOOK) } {"echoFormat":"internal value: \"eb\""}
The Format resolver map says the public name PAPERBACK corresponds to the internal value
"pb". So a resolver must return "pb", and returning the name "PAPERBACK" is an error.
In the other direction, the argument EBOOK arrives in your resolver as "eb". This mapping
is handy when your database stores short codes. Without an enum resolver map (the usual
case) the internal value is simply the name itself.
Lists and non-null: where the ! goes matters¶
{ a } {"a":["x",null,"z"]}
{ b } {"b":null} b,1: Cannot return null for non-nullable field Query.b.
{ c } {"c":["x",null,"z"]}
{ d } null d,1: Cannot return null for non-nullable field Query.d.
All four resolvers returned ["x", null, "z"]:
| SDL | List may be null? | Items may be null? | Result with a null item |
|---|---|---|---|
[String] |
yes | yes | list returned as is |
[String!] |
yes | no | item 1 is invalid → the whole list becomes null |
[String]! |
no | yes | list returned as is |
[String!]! |
no | no | item invalid → list can't be null either → data itself is null |
The error's path (b,1, printed from the array ["b", 1]) points at the exact item that
broke the rule. In the last case the null bubbled past d to the root, and since Query
fields are the top of the response, data became null. Lesson 08 covers
this "null propagation" in depth — it's the single most important reason to think hard
before adding !.
Input coercion: strict¶
{ echoInt(n: 3000000000) } Int cannot represent non 32-bit signed integer value: 3000000000
{ echoInt(n: "5") } Int cannot represent non-integer value: "5"
query($n: Int!) { echoInt(n: $n) } {"n":"5"} Variable "$n" got invalid value "5"; Int cannot represent non-integer value: "5"
query($n: Int!) { echoInt(n: $n) } {"n":5} {"echoInt":5}
{ echoFormat(f: "EBOOK") } Enum "Format" cannot represent non-enum value: "EBOOK". Did you mean the enum value "EBOOK"?
Inputs are not converted the way outputs are. A string "5" is never accepted as an Int,
and an enum value must be written as a bare name (EBOOK), not a string. Inline literals
are rejected during validation; variable values are rejected during variable
coercion, just before execution. Either way, no resolver runs and there's no data. The
5.0 variable passed because JSON has no separate integer type — 5.0 and 5 are the
same JavaScript number.
The asymmetry is deliberate: the server is trusted to know what it means, so mild output conversion is fine; client input is not trusted, so it must match exactly.
Object types and fields with arguments¶
Any field — not just root fields — can take arguments:
type Book {
id: ID!
title: String!
"Cover image URL, scaled to the requested width in pixels."
coverUrl(width: Int = 300): String
format: Format!
}
width: Int = 300 gives a default value: if the client omits the argument, the
resolver receives 300. The string just before a field is a description; it appears in
introspection and in tools like Apollo Sandbox, so it's real documentation, not a comment.
(Comments start with # and are discarded by the parser.)
How It Actually Works¶
Each scalar is a GraphQLScalarType with three functions:
serialize(value)— runs on resolver output to produce the JSON value. ForIntit accepts numbers, booleans and numeric strings, checks integrality and the 32-bit range, and throws aGraphQLErrorotherwise.parseLiteral(ast)— runs on inline argument values in the query text. ForIntit only accepts anIntValueAST node within range; aStringValuenode is rejected.parseValue(value)— runs on variable values from the JSONvariablesmap. ForIntit requirestypeof value === "number"and an integer.
That's why outputs are lenient and inputs strict: they're simply different functions, and the built-in input functions are written conservatively. When you define your own scalars in Level 2 · 03 you'll write these three functions yourself.
Enums work the same way: GraphQLEnumType.serialize maps internal value → name (failing if
no value matches) and parseLiteral/parseValue map name → internal value.
Non-null is a wrapper type, GraphQLNonNull(inner). After a field's value is completed,
the executor checks: if the field type is non-null and the value is null, it raises
"Cannot return null for non-nullable field", and the null is pushed up to the nearest
nullable ancestor. List completion does the same check for each item when the item type is
non-null.
Common mistakes¶
- Using
Intfor large numbers. IDs from abigintcolumn, file sizes, prices in minor units for large amounts, epoch milliseconds — all can exceed 2³¹−1 and will fail at runtime only when the large value shows up. - Returning the enum name instead of its internal value when you've defined an enum
resolver map. Or the reverse: defining a map, then comparing
args.format === "EBOOK"in a resolver that now receives"eb". - Putting
!everywhere because "it's never null in the database". A non-null field that fails takes its parent down with it. - Expecting
"5"to be accepted as anIntargument because the response converted"12"happily. Input and output rules are different. - Selecting an object without sub-fields (
{ book }), or selecting sub-fields of a scalar. Both are validation errors.
Exercise¶
- Add a field
big: Intwhose resolver returnsDate.now(). What does the client see? Change the type toFloatand then toStringand compare. - Predict, then check, what
[[String!]]does when the resolver returns[["a"], ["b", null]], and what[[String]!]!does with[null, ["a"]]. - Add an enum
Status { ACTIVE ARCHIVED }with internal values1and0. Write a resolver that receives the enum as an argument and returns it; confirm what the resolver sees and what the client sees. - Add a
coverUrl(width: Int = 300)field and verify the default reaches your resolver when the argument is omitted, and thatcoverUrl(width: null)passesnullrather than the default. (Explicitnulland "not provided" are different things in GraphQL.)