JSON in GraphQL: JSON Scalar, Variables, and Response Parsing
Last updated:
GraphQL uses JSON as its wire format — every GraphQL request is a JSON POST body with query, variables, and operationName fields, and every response has a {"data": {...}, "errors": [...]}envelope. GraphQL's type system does not include a native JSON type — arbitrary JSON objects require a custom JSON scalar (for example, the graphql-scalars package's JSONObjectResolver), and passing structured data as a GraphQL variable requires JSON-serializable values. This guide covers the GraphQL JSON wire format, custom JSON scalar implementation, using JSON in variables, parsing nested response data in TypeScript, and graphql-codegen type generation. Use Jsonic's JSON formatter to validate and pretty-print any GraphQL response JSON while debugging.
GraphQL JSON wire format: request body, response envelope, Content-Type
Every GraphQL operation is transported as a JSON document over HTTP. The wire format is standardised: the client sends a POST request with Content-Type: application/json, and the body is a JSON object with up to three fields. Understanding this format is the prerequisite for every other topic in this guide.
The request body fields are: query (string, required — the GraphQL document), variables (object, optional — a map of variable names to JSON-serializable values), and operationName (string, optional — selects one named operation when the document contains multiple). The response is always a JSON object with up to three top-level keys: data (the query result), errors (array of error objects, present only when at least one error occurred), and extensions (optional server metadata such as tracing or cache hints).
// Minimal GraphQL request — POST /graphql
// Headers: Content-Type: application/json
{
"query": "query GetUser($id: ID!) { user(id: $id) { id name email } }",
"variables": { "id": "42" },
"operationName": "GetUser"
}
// Successful response — HTTP 200
{
"data": {
"user": {
"id": "42",
"name": "Alice",
"email": "alice@example.com"
}
}
}
// Partial-success response — HTTP 200 even with errors
{
"data": {
"user": { "id": "42", "name": "Alice", "email": null }
},
"errors": [
{
"message": "Email not visible to this user",
"locations": [{ "line": 1, "column": 41 }],
"path": ["user", "email"],
"extensions": { "code": "FORBIDDEN" }
}
]
}GraphQL also supports queries (not mutations) over HTTP GET: GET /graphql?query={...}&variables=%7B%22id%22%3A42%7D. The variables value must be URL-encoded JSON. GET is useful for CDN caching of public queries but is not supported by all servers. For mutations, POST is always required. See the GraphQL JSON response format guide for a deep dive into the response envelope.
JSON variables in GraphQL: passing objects, arrays, null, and type coercion
GraphQL variables are the correct way to pass dynamic values into a query or mutation. They prevent GraphQL injection attacks (analogous to SQL injection via string concatenation), enable server-side query document caching, and make the query reusable without modification. The variables object is a flat or nested JSON object whose keys match the variable declarations in the query.
// ✅ Correct — use variables for all dynamic values
const body = JSON.stringify({
query: `
mutation CreateProduct($input: ProductInput!, $tags: [String!]!) {
createProduct(input: $input, tags: $tags) {
id
slug
}
}
`,
variables: {
input: {
name: 'GraphQL Scalar Guide',
price: 29.99,
inStock: true,
metadata: null, // null is valid JSON and a valid GraphQL value
},
tags: ['graphql', 'json', 'typescript'],
},
})
// ❌ Dangerous — never interpolate user input into the query string
// query: `mutation { createProduct(name: "${name}") { id } }`Type coercion rules for GraphQL variables: JSON numbers are coerced to Int or Float based on the schema type; JSON strings are coerced to String, ID, or custom scalars like DateTime; JSON booleans map to Boolean; JSON arrays map to GraphQL list types; JSON objects map to GraphQL input object types. The key constraint: variable values must be JSON-serializable. undefined is silently dropped by JSON.stringify (use null instead), Date objects are serialized to ISO strings (which is correct for DateTime scalars), and circular references throw a JSON serialization error. See the JSON in GraphQL variables guide for advanced coercion cases.
// Common variable pitfalls
const variables = {
// ✅ null explicitly passed — server receives null
optionalField: null,
// ❌ undefined — JSON.stringify drops the key entirely
// missingField: undefined, // key vanishes from payload!
// ✅ Date converted to ISO string for DateTime scalar
createdAt: new Date().toISOString(), // "2026-05-19T10:00:00.000Z"
// ❌ Raw Date — JSON.stringify converts to string anyway,
// but being explicit avoids surprises
// createdAt: new Date(),
// ✅ Nested objects are fine — they map to InputObject types
address: { street: '123 Main St', city: 'Portland', zip: '97201' },
}Custom JSON scalar: graphql-scalars JSONObject and manual ScalarTypeDefinition
GraphQL's built-in scalar types (String, Int, Float, Boolean, ID) do not include a type for arbitrary JSON objects. When a field can contain any JSON structure — a metadata blob, a configuration object, a dynamic event payload — you need a custom scalar. The community standard is the graphql-scalars package.
# 1. Declare the scalar in your SDL schema
scalar JSONObject
type Product {
id: ID!
name: String!
# metadata accepts any JSON object
metadata: JSONObject
}
type Mutation {
updateMetadata(id: ID!, metadata: JSONObject!): Product
}// 2. Wire the scalar resolver — Apollo Server example
import { ApolloServer } from '@apollo/server'
import { GraphQLJSONObject } from 'graphql-scalars'
import { typeDefs } from './schema'
import { resolvers as appResolvers } from './resolvers'
const server = new ApolloServer({
typeDefs,
resolvers: {
// Map the scalar name to its implementation
JSONObject: GraphQLJSONObject,
...appResolvers,
},
})
// 3. Resolver returns a plain object — scalar handles serialization
const resolvers = {
Mutation: {
updateMetadata: async (_, { id, metadata }) => {
// metadata is already a plain JS object here
await db.products.update(id, { metadata })
return db.products.findById(id)
},
},
}
To implement the scalar manually without graphql-scalars, create a GraphQLScalarType with three methods: serialize (called when sending the value to the client — return the value as-is for JSON objects), parseValue (called when receiving a variable value — validate and return), and parseLiteral (called when the value appears as an inline literal in the query document — use valueFromASTUntyped from graphql package). The graphql-scalars implementation handles all three correctly including nested object literals in query documents, which is a common edge case to get wrong in manual implementations.
// Manual scalar implementation (for reference — prefer graphql-scalars)
import { GraphQLScalarType } from 'graphql'
import { valueFromASTUntyped } from 'graphql'
export const JSONObjectScalar = new GraphQLScalarType({
name: 'JSONObject',
description: 'Arbitrary JSON object scalar type',
serialize(value) {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new TypeError('JSONObject must be an object')
}
return value
},
parseValue(value) {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new TypeError('JSONObject must be an object')
}
return value
},
parseLiteral(ast) {
// valueFromASTUntyped handles ObjectValue, ListValue, etc.
return valueFromASTUntyped(ast)
},
})Apollo Client JSON handling: cache normalization, response parsing, optimistic updates
Apollo Client processes GraphQL JSON responses automatically: it reads the data and errors fields, normalizes the data into its in-memory cache, and surfaces the result to your React components via useQuery and useMutation. JSON scalar fields (typed as JSONObject in the schema) pass through the Apollo cache without normalization — Apollo treats them as opaque values since they have no __typename or id field to normalize on.
// Apollo Client: query with JSONObject field
import { gql, useQuery } from '@apollo/client'
const GET_PRODUCT = gql`
query GetProduct($id: ID!) {
product(id: $id) {
id
name
metadata # JSONObject scalar — returned as-is
}
}
`
function ProductCard({ id }: { id: string }) {
const { data, loading, error } = useQuery(GET_PRODUCT, {
variables: { id },
})
if (loading) return <div>Loading...</div>
if (error) return <div>Error: {error.message}</div>
// data.product.metadata is a plain JS object
const { name, metadata } = data.product
return (
<div>
<h2>{name}</h2>
{metadata?.color && <span>Color: {metadata.color}</span>}
</div>
)
}For optimistic updates that include a JSONObject field, pass the expected JSON object directly in the optimisticResponse: optimisticResponse: { updateProduct: { __typename: 'Product', id, metadata: newMetadata } }. Apollo merges this into the cache immediately before the server responds. Cache normalization relies on __typename + id — since JSONObject values lack these, Apollo stores the entire object as a single cache field and replaces it on update (no deep merge). If you need deep merge of JSON objects in the cache, use a custom merge function in the Apollo cache field policy.
// Apollo InMemoryCache field policy for JSONObject merge
import { InMemoryCache } from '@apollo/client'
const cache = new InMemoryCache({
typePolicies: {
Product: {
fields: {
// Deep-merge metadata instead of replacing it
metadata: {
merge(existing = {}, incoming) {
return { ...existing, ...incoming }
},
},
},
},
},
})TypeScript types for GraphQL JSON: graphql-codegen, typed JSON scalar, useQuery types
The JSONObject scalar has no built-in TypeScript type — you must map it to a TypeScript type via graphql-codegen configuration. The standard mapping is Record<string, unknown> for maximum compatibility, or JsonValue from the type-fest package for a stricter recursive type that includes arrays, primitives, and nested objects. See the TypeScript JSON types guide for background on JsonValue and related utility types.
# codegen.yml
generates:
src/generated/graphql.ts:
plugins:
- typescript
- typescript-operations
config:
scalars:
# Map JSONObject scalar to TypeScript type
JSONObject: "Record<string, unknown>"
# Or stricter — requires: npm install type-fest
# JSONObject: "import('type-fest').JsonValue"
DateTime: "string"
ID: "string"// Generated type for a field using JSONObject scalar
// src/generated/graphql.ts (auto-generated — do not edit)
export type Product = {
__typename?: 'Product'
id: Scalars['ID']
name: Scalars['String']
metadata?: Scalars['JSONObject'] // resolves to Record<string, unknown>
}
// In your component:
import type { GetProductQuery } from '@/generated/graphql'
function useProduct(id: string) {
const { data } = useQuery<GetProductQuery>(GET_PRODUCT, { variables: { id } })
// metadata is Record<string, unknown> | undefined
const metadata = data?.product?.metadata
// Narrow the type before use
const color = typeof metadata?.color === 'string' ? metadata.color : null
return { product: data?.product, color }
}Without graphql-codegen, define the response type manually using a generic GraphQLResponse<T> interface. Type the JSONObject fields as Record<string, unknown> and use type guard functions to narrow values before use. Never cast JSON scalar values to specific interfaces with as — validate the shape at runtime using a library like zod or write explicit type guards.
JSON fragments and nested response parsing: destructuring, optional chaining, type narrowing
GraphQL fragments let you declare a reusable set of fields that can be spread into multiple queries. When a fragment includes a JSONObject field, the JSON value travels through the response as part of the standard data tree and requires the same parsing techniques as any deeply nested JSON. The key techniques are destructuring, optional chaining, and type narrowing — the same patterns used for REST API JSON response parsing.
// Fragment with a JSONObject field
const PRODUCT_META_FRAGMENT = gql`
fragment ProductMeta on Product {
id
name
metadata # JSONObject
variants {
id
price
attributes # JSONObject — per-variant arbitrary attributes
}
}
`
// Parsing nested response data in TypeScript
function parseProductData(data: GetProductQuery | undefined) {
if (!data?.product) return null
const { id, name, metadata, variants } = data.product
// Optional chaining for JSONObject fields
const primaryColor = metadata?.color as string | undefined
const tags = Array.isArray(metadata?.tags) ? metadata.tags as string[] : []
// Parse nested list with JSONObject per item
const parsedVariants = (variants ?? []).map((v) => ({
id: v.id,
price: v.price,
// Type narrowing for nested JSON
size: typeof v.attributes?.size === 'number' ? v.attributes.size : null,
label: typeof v.attributes?.label === 'string' ? v.attributes.label : '',
}))
return { id, name, primaryColor, tags, variants: parsedVariants }
}For complex JSONObject fields where the structure is known but not codegen-typed, use a zod schema to parse and validate at runtime:
import { z } from 'zod'
const MetadataSchema = z.object({
color: z.string().optional(),
tags: z.array(z.string()).default([]),
weight: z.number().optional(),
}).passthrough() // allow extra keys from the JSON scalar
// At the point of consumption:
const parsedMeta = MetadataSchema.safeParse(data.product.metadata)
if (parsedMeta.success) {
console.log(parsedMeta.data.color) // string | undefined, fully typed
}GraphQL error handling: errors array, partial success, HTTP 200 with errors, network errors
GraphQL has two distinct error categories that require different handling strategies. Network errors occur when the HTTP request itself fails (connection refused, DNS failure, non-200 status for a completely unparseable request) and are signaled by a non-OK HTTP response or a fetch exception. GraphQL errors occur when the server executed the operation but one or more resolvers failed — these always return HTTP 200 and appear in the errors array alongside whatever partial data was successfully resolved.
async function graphqlFetch<T>(
query: string,
variables?: Record<string, unknown>
): Promise<{ data: T | null; errors: GraphQLError[] | null }> {
// Network error — fetch throws or response is not OK
let res: Response
try {
res = await fetch('/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables }),
})
} catch (networkErr) {
throw new Error(`Network error: ${networkErr}`)
}
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`)
}
// GraphQL error — HTTP 200 but body contains errors
const json = await res.json()
return {
data: json.data ?? null,
errors: json.errors ?? null,
}
}
// Usage — handle both error types
const { data, errors } = await graphqlFetch<{ product: Product }>(
GET_PRODUCT_QUERY,
{ id: '42' }
)
if (errors) {
for (const err of errors) {
const code = err.extensions?.code
if (code === 'UNAUTHENTICATED') redirectToLogin()
else if (code === 'NOT_FOUND') showNotFound()
else console.error('GraphQL error:', err.message)
}
}
// data may be partially populated even when errors exist
if (data?.product) {
renderProduct(data.product)
}Partial success — a response with both data and errors — is a deliberate feature of the GraphQL spec, not a bug. A query for 10 fields where 1 resolver throws returns the 9 successful fields in data with the failed field set to null and described in errors. Build your UI to handle this: show available data, mark unavailable fields clearly, and do not treat the presence of errors as a reason to discard data entirely. Always classify errors by extensions.code rather than message — error messages may change between server versions, while error codes are treated as part of the API contract.
Definitions
- GraphQL scalar
- A leaf type in the GraphQL type system that represents a primitive value. Built-in scalars are
String,Int,Float,Boolean, andID. Custom scalars extend this set with domain-specific types such asDateTime,URL, andJSONObject. Scalars define serialize, parseValue, and parseLiteral functions that control how values are coerced to and from the JSON wire format. - JSON scalar
- A custom GraphQL scalar type that accepts any valid JSON value — objects, arrays, strings, numbers, booleans, or null — without schema-level type checking. Declared with
scalar JSONObjectin SDL and implemented with a resolver that passes values through without transformation. Thegraphql-scalarspackage provides a production-ready implementation. - Variables
- Named, typed placeholders in a GraphQL operation that receive values from the request body's
variablesJSON object. Declared with a$prefix in the operation signature (e.g.,$id: ID!) and referenced in the operation body (e.g.,user(id: $id)). Variables separate the operation document from its runtime values, enabling caching and preventing injection attacks. - Response envelope
- The top-level JSON object structure of every GraphQL response:
{"data": { ... }, "errors": [...], "extensions": { ... } }. Thedatakey is present when execution occurred (even partially). Theerrorskey is present only when at least one error occurred. Theextensionskey is optional and carries server-defined metadata. - Partial success
- A GraphQL response that contains both a populated
dataobject and a non-emptyerrorsarray. Occurs when some resolvers succeed and others fail within a single operation. The failed fields are set tonullin the response and described in theerrorsarray with apathpointing to the failed field. The HTTP status code is 200 regardless. - graphql-scalars
- An npm package (
npm install graphql-scalars) that provides production-ready implementations of common custom GraphQL scalars includingJSONObject,DateTime,EmailAddress,URL,UUID, and many others. Each scalar exports aGraphQLScalarTypeinstance that can be added directly to the resolvers map and a corresponding SDL type definition string for the schema. - graphql-codegen
- A code generation tool (
npm install @graphql-codegen/cli) that reads a GraphQL schema and operation documents and generates TypeScript types, React hooks, and other client code. Scalar mappings incodegen.ymlcontrol which TypeScript type is generated for each custom scalar — for example,JSONObject: "Record<string, unknown>"makes everyJSONObjectfield in generated types useRecord<string, unknown>.
Frequently asked questions
How do I send JSON data as a GraphQL variable?
Pass the JSON-serializable value in the variables object of the POST body. The request body is: {"query": "mutation Save($meta: JSONObject!) { save(meta: $meta) { id } } ", "variables": { "meta": { "color": "blue", "size": 42 } } }. Do not serialize the value yourself — pass the plain JavaScript object as the variable value and let JSON.stringify handle the entire body. All variable values must be JSON-serializable: no undefined (it is silently dropped — use null instead), no raw Date objects (convert with .toISOString() first), and no circular references. If the field type in the schema is a custom JSON scalar such as JSONObject from graphql-scalars, the server accepts any object value for that variable.
What is the GraphQL JSON scalar type?
GraphQL has no built-in JSON or Any scalar. A custom JSON scalar is a scalar type definition that accepts any valid JSON value — objects, arrays, strings, numbers, booleans, and null — without further type-checking by the GraphQL type system. The most widely used implementation is JSONObject from the graphql-scalars package. It provides serialize, parseValue, and parseLiteral functions that round-trip arbitrary JSON through the GraphQL layer. The schema definition is: scalar JSONObject. Field resolvers return plain JavaScript objects; the scalar handles serialization to the JSON wire format automatically.
How do I handle arbitrary JSON objects in a GraphQL schema?
Define a custom scalar in the schema (scalar JSONObject) and wire it to a resolver implementation. The simplest approach is to use the graphql-scalars package: import { GraphQLJSONObject } from 'graphql-scalars' and add JSONObject: GraphQLJSONObject to your resolvers map. Fields typed as JSONObject can return any JavaScript object from their resolver and receive any JSON-serializable object as an input variable. This pattern is useful for metadata fields, config blobs, and event payload fields where the structure is not known at schema design time. For fields that always return a specific shape, model them as proper GraphQL types instead — it enables field-level querying and better client-side caching.
Why does GraphQL return HTTP 200 even when there are errors?
GraphQL uses HTTP 200 for field-level errors because the HTTP transport layer succeeded — the server received a valid request, executed the operation, and returned a well-formed JSON response. The GraphQL spec separates transport errors (HTTP status codes) from application errors (the errors array in the response body). A field resolver throwing an exception, a permission check failing, or a downstream service being unavailable are all application-layer events that result in HTTP 200 with an errors array. You must always check the JSON body for an errors key regardless of the HTTP status. The only cases where GraphQL returns non-200 are: 400 for a completely unparseable query, 405 for a mutation sent via GET, and 500 for catastrophic pre-execution failures. Compare this to REST API JSON responses where HTTP status codes are the primary error mechanism.
How do I parse GraphQL JSON responses in TypeScript?
Define a generic GraphQLResponse<T> interface: interface GraphQLResponse<T> { data?: T; errors?: Array<{ message: string; path?: (string | number)[]; extensions?: Record<string, unknown> }> }. Then type the fetch call: const result = await res.json() as GraphQLResponse<{ user: User }>. For the JSON scalar field specifically, type it as Record<string, unknown> or JsonValue from type-fest. With graphql-codegen, set the scalar mapping in codegen.yml: scalars: JSONObject: "Record<string, unknown>". The generated types will use Record<string, unknown> wherever JSONObject appears, giving you a typed but open-ended type for arbitrary JSON fields. See the TypeScript JSON types guide for the full range of JSON type utilities.
Can I use Date objects in GraphQL variables?
No. GraphQL variables are serialized via JSON.stringify before being sent in the HTTP POST body. JSON.stringify converts Date objects to ISO 8601 strings (e.g., "2026-05-19T10:00:00.000Z"), so the server receives a string, not a Date. This is usually the desired behavior when using a custom DateTime scalar. The issue arises with undefined values: JSON.stringify omits undefined keys entirely, which can cause variables to be missing from the payload unexpectedly. Always convert Date to string explicitly with date.toISOString() and replace undefined with null before passing values as GraphQL variables. Use Jsonic's formatter to inspect the serialized variable payload and catch missing keys before sending.
How do I implement a custom JSON scalar in Apollo Server?
Add scalar JSONObject to your SDL type definitions, then add JSONObject: GraphQLJSONObject from graphql-scalars to your resolvers. Install with npm install graphql-scalars, import {GraphQLJSONObject} from 'graphql-scalars', and include it in the resolvers map passed to ApolloServer. The scalar handles serialization in both directions: returning objects from resolvers (via serialize) and receiving objects in input variables (via parseValue) and inline query literals (via parseLiteral). The graphql-scalars approach is strongly preferred over a manual implementation because it correctly handles nested object literals in query documents, which is a common edge case to get wrong.
How do I type GraphQL JSON scalar fields with graphql-codegen?
In codegen.yml, add a scalars mapping under the TypeScript plugin config: config: scalars: JSONObject: "Record<string, unknown>". For stricter typing, use the JsonValue type from type-fest: scalars: JSONObject: "import('type-fest').JsonValue". This maps the JSONObject scalar to the TypeScript type everywhere it appears in generated types. For input types (mutation variables), the same mapping applies: a JSONObject input variable is typed as Record<string, unknown> in the generated mutation variables interface. Run graphql-codegen after schema changes to regenerate types with the scalar mappings applied.
Further reading and primary sources
- GraphQL Specification — Scalars — Official spec section defining built-in and custom scalar types
- graphql-scalars — JSONObject — The Guild's documentation for the JSONObject scalar implementation
- graphql-codegen Scalar Configuration — How to map custom scalars to TypeScript types in codegen.yml
- GraphQL over HTTP Specification — Formal specification for the GraphQL wire format, Content-Type rules, and GET vs POST
- Apollo Server Custom Scalars — Apollo Server documentation for implementing and registering custom scalar types
Debug GraphQL JSON responses visually
Paste any GraphQL response into Jsonic to pretty-print and navigate the nested data/errors/extensions structure. Instantly spot null fields from failed resolvers and validate JSON scalar payloads.