JSON in GraphQL: Variables, Scalars, and Responses
Last updated:
GraphQL and JSON are deeply intertwined: every GraphQL HTTP request is a JSON object, every response is JSON, and storing arbitrary data in a GraphQL field requires a custom JSON scalar. This guide covers all three layers — the request format, the JSON/JSONObject scalar types, parsing the response envelope, using graphql-request, and generating TypeScript types with graphql-codegen.
GraphQL Request Format as JSON
Every GraphQL operation is sent over HTTP as a JSON POST body. The body is a JSON object with three possible keys: query (the GraphQL document string, required), variables (a JSON object, optional), and operationName (a string, optional). This structure is defined by the GraphQL over HTTP specification.
// Minimal request — query only
{ "query": "query { users { id name } }" }
// Full request with variables and operationName
{
"query": "query GetUser($id: ID!, $includeEmail: Boolean!) { user(id: $id) { name email @include(if: $includeEmail) } }",
"variables": {
"id": "usr_42",
"includeEmail": true
},
"operationName": "GetUser"
}
// Send with fetch — standard HTTP POST
const response = await fetch('https://api.example.com/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + token,
},
body: JSON.stringify({
query: `
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
}
}
`,
variables: { id: 'usr_42' },
}),
})
const json = await response.json()
// json.data.user → { id: "usr_42", name: "Alice", email: "alice@example.com" }
// json.errors → undefined on successThe operationName field is required only when the document contains multiple named operations. It selects which operation to execute. For mutations, use the mutation keyword in the query string — the HTTP method and body format are identical to queries.
Query Variables
Variables decouple dynamic values from the query string. Instead of interpolating user input directly into the query, declare typed variables in the operation signature and pass values in the variables object. This is required for security (prevents GraphQL injection) and performance (servers can cache parsed query documents by hash when the query string is constant).
// ✅ Correct — variables keep the query string constant
const query = `
mutation CreateProduct($input: CreateProductInput!) {
createProduct(input: $input) {
id
name
price
}
}
`
const variables = {
input: {
name: 'Widget Pro',
price: 29.99,
category: 'tools',
metadata: { sku: 'WGT-001', warehouse: 'US-WEST' },
},
}
const body = JSON.stringify({ query, variables })
// ❌ Unsafe — never interpolate user data into query strings
// const query = `mutation { createProduct(name: "${userInput}") { id } }`
// This bypasses query caching and opens injection vulnerabilitiesVariable Type Declarations
Variable types in the GraphQL operation must match the schema. Declare them after the operation name in parentheses. Non-null types use !; lists use []:
# String variable — nullable
query Search($term: String) { search(term: $term) { id title } }
# Non-null ID variable
query GetPost($id: ID!) { post(id: $id) { title body } }
# Non-null list of non-null IDs
query GetPosts($ids: [ID!]!) { posts(ids: $ids) { id title } }
# Input object variable
mutation UpdateUser($id: ID!, $input: UpdateUserInput!) {
updateUser(id: $id, input: $input) { id name email }
}
# JSON scalar variable (custom scalar — see Section 3)
mutation SaveMetadata($id: ID!, $meta: JSON!) {
saveMetadata(id: $id, meta: $meta) { ok }
}JSON Scalar Type
GraphQL has five built-in scalars — String, Int, Float, Boolean, and ID — but no JSON type. To store arbitrary JSON data in a field, define a custom scalar. The graphql-scalars library provides production-ready JSON and JSONObject scalars.
npm install graphql-scalars# schema.graphql — declare the custom scalars
scalar JSON
scalar JSONObject
type Product {
id: ID!
name: String!
price: Float!
metadata: JSONObject # arbitrary key-value object
variants: JSON # any JSON value (array, object, string, etc.)
}
type Query {
product(id: ID!): Product
}
type Mutation {
updateMetadata(id: ID!, metadata: JSONObject!): Product
}// resolvers.ts — wire up the graphql-scalars resolvers
import { GraphQLJSON, GraphQLJSONObject } from 'graphql-scalars'
export const resolvers = {
JSON: GraphQLJSON,
JSONObject: GraphQLJSONObject,
Query: {
product: async (_: unknown, { id }: { id: string }) => {
return db.products.findById(id)
// metadata field returns a plain JS object — serialized automatically
},
},
Mutation: {
updateMetadata: async (
_: unknown,
{ id, metadata }: { id: string; metadata: Record<string, unknown> }
) => {
// metadata is already a plain JS object — no JSON.parse needed
return db.products.update(id, { metadata })
},
},
}JSON vs JSONObject Scalar
| Scalar | Accepts | Rejects | Best for |
|---|---|---|---|
JSON | Any JSON value: object, array, string, number, boolean, null | Nothing (any valid JSON) | Heterogeneous or unknown-shape data |
JSONObject | JSON objects only: {"key": "value"} | Arrays, primitives, null | Metadata maps, settings, custom attributes |
Prefer JSONObject when you know the field will always be a key-value map — it gives clients a stronger contract. Use JSON only when the field genuinely could be any JSON value, including arrays or primitives.
Parsing GraphQL JSON Responses
Every GraphQL response is a JSON object with a fixed envelope structure. The top-level keys are data (the query result), errors (an array of error objects, present only when at least one error occurred), and extensions (optional server metadata). Both data and errors can appear together in a partial-success response.
// Successful response
{
"data": {
"product": {
"id": "prod_1",
"name": "Widget Pro",
"metadata": { "sku": "WGT-001", "warehouse": "US-WEST" }
}
}
}
// Partial success — data and errors coexist
{
"data": {
"product": {
"id": "prod_1",
"name": "Widget Pro",
"metadata": null
}
},
"errors": [
{
"message": "Metadata access denied",
"locations": [{ "line": 5, "column": 5 }],
"path": ["product", "metadata"],
"extensions": { "code": "FORBIDDEN" }
}
]
}
// Fatal error — data is null
{
"data": null,
"errors": [
{
"message": "Product not found",
"locations": [{ "line": 2, "column": 3 }],
"path": ["product"],
"extensions": { "code": "NOT_FOUND" }
}
]
}Parsing the Envelope in TypeScript
// Define a typed envelope wrapper
interface GraphQLResponse<T> {
data?: T
errors?: Array<{
message: string
path?: Array<string | number>
locations?: Array<{ line: number; column: number }>
extensions?: Record<string, unknown>
}>
extensions?: Record<string, unknown>
}
// Generic fetch wrapper
async function graphqlFetch<T>(
endpoint: string,
query: string,
variables?: Record<string, unknown>
): Promise<GraphQLResponse<T>> {
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables }),
})
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`)
return res.json() as Promise<GraphQLResponse<T>>
}
// Usage — always check both data and errors
const { data, errors } = await graphqlFetch<{ product: Product }>(
'/graphql',
`query GetProduct($id: ID!) { product(id: $id) { id name metadata } }`,
{ id: 'prod_1' }
)
if (errors) {
// Log all errors; data may still be partially available
for (const err of errors) {
console.error(`[${err.extensions?.code}] ${err.message} at ${err.path?.join('.')}`)
}
}
if (data?.product) {
const { name, metadata } = data.product
// metadata is typed as unknown (JSON scalar) — validate before use
if (typeof metadata === 'object' && metadata !== null && 'sku' in metadata) {
console.log((metadata as { sku: string }).sku)
}
}graphql-request Client
graphql-request is a minimal GraphQL client (~5 kB gzipped) that handles the JSON serialization and envelope parsing automatically. It sets Content-Type: application/json, serializes variables with JSON.stringify, and returns response.data directly — not the full envelope.
npm install graphql-request graphqlimport { GraphQLClient, gql } from 'graphql-request'
const client = new GraphQLClient('https://api.example.com/graphql', {
headers: { Authorization: 'Bearer ' + process.env.API_TOKEN },
})
// Define the query with the gql tagged template literal
const GET_PRODUCT = gql`
query GetProduct($id: ID!) {
product(id: $id) {
id
name
price
metadata # JSON scalar — returned as plain JS object
}
}
`
// Define a TypeScript type for the response
type ProductQuery = {
product: {
id: string
name: string
price: number
metadata: Record<string, unknown> | null
}
}
// request() returns data directly — envelope is unwrapped
const data = await client.request<ProductQuery>(GET_PRODUCT, { id: 'prod_1' })
console.log(data.product.name) // 'Widget Pro'
console.log(data.product.metadata) // { sku: 'WGT-001', warehouse: 'US-WEST' }
// Error handling — graphql-request throws ClientError on GraphQL errors
import { ClientError } from 'graphql-request'
try {
const data = await client.request<ProductQuery>(GET_PRODUCT, { id: 'not-found' })
} catch (err) {
if (err instanceof ClientError) {
console.error(err.response.errors) // GraphQL errors array
console.error(err.response.status) // HTTP status
}
}Sending JSON Variables
Variables are passed as a plain JavaScript object — graphql-request calls JSON.stringify internally. For JSON scalar fields, the value is a plain JS object or array:
const UPDATE_METADATA = gql`
mutation UpdateMetadata($id: ID!, $metadata: JSONObject!) {
updateMetadata(id: $id, metadata: $metadata) {
id
metadata
}
}
`
type UpdateMetadataMutation = {
updateMetadata: {
id: string
metadata: Record<string, unknown>
}
}
// metadata is a plain JS object — no JSON.stringify needed
const result = await client.request<UpdateMetadataMutation>(UPDATE_METADATA, {
id: 'prod_1',
metadata: {
sku: 'WGT-002',
warehouse: 'EU-CENTRAL',
tags: ['sale', 'featured'],
dimensions: { weight: 0.5, unit: 'kg' },
},
})
console.log(result.updateMetadata.metadata)
// { sku: 'WGT-002', warehouse: 'EU-CENTRAL', tags: [...], dimensions: {...} }TypeScript Types with graphql-codegen
graphql-codegen generates TypeScript types from your GraphQL schema and query files, keeping types always in sync with the schema. For JSON scalar fields, configure the scalar mapping to control the generated TypeScript type.
npm install --save-dev @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-operations// codegen.ts — configure scalar type mappings
import type { CodegenConfig } from '@graphql-codegen/cli'
const config: CodegenConfig = {
schema: 'schema.graphql',
documents: 'src/**/*.graphql',
generates: {
'src/generated/graphql.ts': {
plugins: ['typescript', 'typescript-operations'],
config: {
// Map custom scalars to TypeScript types
scalars: {
JSON: 'unknown',
JSONObject: 'Record<string, unknown>',
// Or use more specific types:
// JSONObject: 'Record<string, string | number | boolean | null>',
},
},
},
},
}
export default config# Run codegen — generates src/generated/graphql.ts
npx graphql-codegen
# Watch mode — regenerates on schema or query changes
npx graphql-codegen --watchGiven the schema from Section 3, codegen generates:
// src/generated/graphql.ts — auto-generated, do not edit
export type Scalars = {
ID: { input: string; output: string }
String: { input: string; output: string }
Boolean: { input: boolean; output: boolean }
Int: { input: number; output: number }
Float: { input: number; output: number }
JSON: { input: unknown; output: unknown }
JSONObject: { input: Record<string, unknown>; output: Record<string, unknown> }
}
export type Product = {
__typename?: 'Product'
id: Scalars['ID']['output']
name: Scalars['String']['output']
price: Scalars['Float']['output']
metadata?: Scalars['JSONObject']['output'] | null
variants?: Scalars['JSON']['output'] | null
}
// Operation-specific types generated from query files
export type GetProductQueryVariables = {
id: Scalars['ID']['input']
}
export type GetProductQuery = {
__typename?: 'Query'
product?: {
__typename?: 'Product'
id: string
name: string
metadata: Record<string, unknown> | null
} | null
}Use the generated types with graphql-request for end-to-end type safety:
import { GetProductQuery, GetProductQueryVariables } from './generated/graphql'
const data = await client.request<GetProductQuery, GetProductQueryVariables>(
GET_PRODUCT,
{ id: 'prod_1' }
)
// Fully typed — no manual interface needed
data.product?.metadata // Record<string, unknown> | null | undefinedError Handling in GraphQL JSON
GraphQL errors appear in the errors array, not in the HTTP status code. Each error object has a required message field and optional path, locations, and extensions fields. The path array maps an error to the exact field that failed; extensions.code provides a machine-readable error code.
// Full error object structure
{
"errors": [
{
"message": "Cannot access metadata: insufficient permissions",
"locations": [{ "line": 5, "column": 5 }],
"path": ["product", "metadata"],
"extensions": {
"code": "FORBIDDEN",
"http": { "status": 403 }
}
}
]
}
// Error handling with error-code classification
async function loadProduct(id: string) {
const res = await fetch('/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: `query GetProduct($id: ID!) {
product(id: $id) { id name metadata }
}`,
variables: { id },
}),
})
if (!res.ok) throw new Error(`Transport error: HTTP ${res.status}`)
const { data, errors } = await res.json()
// Index errors by path for targeted handling
const errorByPath: Record<string, string> = {}
for (const err of errors ?? []) {
const key = (err.path ?? []).join('.')
const code = err.extensions?.code ?? 'UNKNOWN'
errorByPath[key] = code
}
return {
product: data?.product ?? null,
metadataError: errorByPath['product.metadata'] ?? null,
// e.g. 'FORBIDDEN' if metadata was blocked
}
}Error Codes from Apollo Server
| extensions.code | REST equivalent | Meaning |
|---|---|---|
UNAUTHENTICATED | 401 | No valid auth token |
FORBIDDEN | 403 | Authenticated but not authorized |
NOT_FOUND | 404 | Requested resource does not exist |
BAD_USER_INPUT | 422 | Invalid variable values |
INTERNAL_SERVER_ERROR | 500 | Resolver threw an unhandled exception |
Build error-handling logic against extensions.code, not message — messages are human-readable and may change between server versions; codes are stable API contracts.
Key Definitions
- GraphQL scalar
- A GraphQL scalar is a primitive leaf type that resolves to a single value. The five built-in scalars are
String,Int,Float,Boolean, andID, each serialized as the corresponding JSON value type in responses. Custom scalars extend this set with application-specific types. - JSON scalar
- A custom GraphQL scalar — not built into the spec — that accepts any valid JSON value as input or output. The
graphql-scalarslibrary provides a production-ready implementation. JSON scalar fields are typed asunknownin TypeScript generated code because their shape is not known at compile time. - Query variables
- A JSON object sent alongside a GraphQL operation in the
variablesfield of the request body. Variables decouple dynamic values from the query string, prevent injection attacks, and allow servers to cache parsed query documents by their stable string hash. They are declared with typed signatures in the GraphQL operation:query GetUser($id: ID!). - Data envelope
- The top-level structure of every GraphQL JSON response: a JSON object containing a
datakey (the query result), an optionalerrorsarray (field-level errors), and an optionalextensionsobject (server metadata). The envelope is defined by the GraphQL specification and is the same regardless of server implementation. - graphql-codegen
- A code generation tool (
@graphql-codegen/cli) that reads a GraphQL schema file and query documents, then outputs TypeScript types for every schema type and operation. Custom scalar types are mapped to TypeScript types via thescalarsconfig key. Runningnpx graphql-codegenregenerates all types in under one second whenever the schema or queries change.
FAQ
What is the format of a GraphQL request body?
A GraphQL request is sent as an HTTP POST with Content-Type: application/json. The body is a JSON object with three fields: query (the GraphQL operation string, required), variables (a JSON object of variable values, optional), and operationName (a string selecting one named operation, optional). Always use variables for dynamic values rather than string interpolation — this prevents injection attacks and allows the server to cache the parsed query document.
Does GraphQL have a built-in JSON type?
No. GraphQL's five built-in scalars are String, Int, Float, Boolean, and ID. There is no JSON type in the specification. To store arbitrary JSON, define a custom scalar with scalar JSON in your schema and wire up the resolver from the graphql-scalars library. The JSONObject scalar is a stricter variant that accepts only JSON objects, not arrays or primitives.
What is the GraphQL response envelope?
Every GraphQL response is a JSON object with up to three top-level keys: data (the query result, present when execution occurred), errors (an array of error objects, present when at least one error occurred), and extensions (optional server metadata like tracing). Both data and errors can appear together in a partial-success response. Always check for errors before using data, even when the HTTP status is 200.
How do I pass a JSON object as a GraphQL variable?
If your schema defines a JSON or JSONObject scalar, declare the variable with that type in the operation: mutation Save($config: JSONObject!). In the variables object, pass a plain JavaScript object — it will be serialized to JSON by the client. For well-known shapes, prefer a structured GraphQL input type instead of a JSON scalar, which gives you field-level validation and better tooling support.
What is the difference between the JSON scalar and JSONObject scalar?
Both come from graphql-scalars. JSON accepts any valid JSON value: objects, arrays, strings, numbers, booleans, and null. JSONObject accepts only JSON objects (key-value maps) and rejects arrays and primitives. Use JSONObject when you know the field will always be an object — it communicates a stronger contract to API consumers. Use JSON for genuinely heterogeneous data where the shape cannot be predicted.
How does graphql-request handle JSON variables and responses?
graphql-request automatically serializes the variables object to JSON, sets Content-Type: application/json, and unwraps the data envelope — returning response.data directly. Pass a TypeScript generic to get typed results: client.request<MyQueryType>(query, variables). If the server returns errors, it throws a ClientError with error.response.errors and error.response.status. No manual JSON.stringify or envelope parsing is needed.
How do I generate TypeScript types for GraphQL JSON responses?
Install @graphql-codegen/cli and the TypeScript plugins. Create a codegen.ts config that maps custom scalars: scalars: { JSON: "unknown", JSONObject: "Record<string, unknown>" }. Run npx graphql-codegen to generate typed interfaces for every schema type and operation. Use the generated operation types with graphql-request for end-to-end type safety without writing manual interfaces.
Why does GraphQL return HTTP 200 even when there are errors?
GraphQL separates HTTP transport success from application-level errors. A field-level error (a resolver threw, a permission check failed) does not prevent the server from returning a well-formed JSON response — so HTTP 200 is returned. Field errors appear in the errors array. Always inspect the response body for an errors key regardless of HTTP status. Non-200 responses occur only for transport-level failures: 400 for an unparseable query, 405 for a disallowed HTTP method, and 500 for catastrophic server failures before execution begins.
Further reading and primary sources
- GraphQL Spec: HTTP — GraphQL over HTTP specification
- graphql-scalars JSON — graphql-scalars JSON and JSONObject scalar documentation
- graphql-request — Minimal GraphQL client for Node.js and browsers