JSON in GraphQL Resolvers: Return Types, JSON Scalar, Variables & Apollo Server

Last updated:

GraphQL resolvers return plain JavaScript objects — the GraphQL execution engine serializes the return value to JSON, selecting only the fields the client queried. A resolver's signature is (parent, args, context, info) => returnValue where args contains the parsed JSON from query variables. GraphQL HTTP requests use Content-Type: application/json with body {"query": "...", "variables": {...}} — the variables object is standard JSON. For fields with arbitrary or dynamic JSON structure, the JSON scalar from graphql-scalars accepts any JSON value without a fixed type. Mutations receive JSON input via input argument types. This guide covers resolver return types, the JSON scalar, mutation input types, GraphQL variables as JSON, error JSON format, DataLoader for N+1 batching, and returning JSON from Apollo Server 4.

How GraphQL Resolvers Return JSON

A GraphQL resolver is a function that resolves the value of a single field. It returns a plain JavaScript value — object, array, string, number, boolean, or null — and the GraphQL runtime serializes that value to JSON. The serialized output is shaped by the client's selection set: if the resolver returns { id: "1", name: "Alice", password: "secret" } but the query only requests { id name }, the passwordfield is never included in the JSON response. This selective serialization is one of GraphQL's core guarantees.

// schema.graphql
type Query {
  user(id: ID!): User
  users: [User!]!
}

type User {
  id:    ID!
  name:  String!
  email: String!
  role:  String!
}

// resolvers.ts — TypeScript resolver map
import type { Resolvers } from './generated/resolvers'

const resolvers: Resolvers = {
  Query: {
    // parent = root query object (usually ignored at root)
    // args   = { id: "42" } — parsed from query variables
    // ctx    = { db, currentUser, ... } — shared request context
    // info   = GraphQL execution metadata (rarely needed)
    user: async (_parent, args, ctx) => {
      return ctx.db.users.findById(args.id)
      // Returns: { id: "42", name: "Alice", email: "...", role: "admin" }
      // If client queries { user { id name } }, JSON response will only
      // contain id and name — the execution engine handles selection.
    },

    users: async (_parent, _args, ctx) => {
      return ctx.db.users.findAll()
      // Returns an array — GraphQL serializes it as a JSON array.
    },
  },

  // Field resolver — called once per User object in the parent list
  User: {
    // Resolver for a computed field not stored in the DB
    role: (parent, _args, ctx) => {
      // parent = the User object returned by the Query.user resolver
      return ctx.permissions.getRoleForUser(parent.id)
    },
  },
}

// Apollo Server 4 — minimal setup
import { ApolloServer } from '@apollo/server'
import { startStandaloneServer } from '@apollo/server/standalone'
import { readFileSync } from 'fs'

const typeDefs = readFileSync('./schema.graphql', 'utf-8')

const server = new ApolloServer({ typeDefs, resolvers })

const { url } = await startStandaloneServer(server, {
  context: async ({ req }) => ({
    db: createDbClient(),
    currentUser: await authenticate(req.headers.authorization),
  }),
  listen: { port: 4000 },
})

// HTTP response envelope for { user(id: "42") { id name } }:
// {
//   "data": {
//     "user": {
//       "id": "42",
//       "name": "Alice"
//     }
//   }
// }

The execution engine calls resolvers recursively, depth-first. When a resolver returns a Promise, the engine awaits it before calling child field resolvers. Null propagation follows the schema's nullability rules — if a non-null field resolver returns null, the error bubbles up to the nearest nullable ancestor, potentially nulling out a parent object. Understanding this prevents confusing "Cannot return null for non-nullable field" errors in production.

Resolver Arguments: Parsing JSON from Query Variables

The argsparameter of every resolver contains the parsed JSON from the query's variables object, already coerced to the declared GraphQL types. The GraphQL runtime validates and coerces variables before resolver execution — resolvers receive clean, typed values, not raw JSON strings. Input object types map directly to JSON objects; list types map to JSON arrays; scalar types (String, Int, Float, Boolean, ID) coerce from their JSON counterparts.

// schema.graphql — input types for mutations
input CreateUserInput {
  name:     String!
  email:    String!
  role:     String
  metadata: JSON    # arbitrary JSON blob (graphql-scalars)
}

input UserFilter {
  nameContains: String
  role:         String
  minAge:       Int
  limit:        Int!
  offset:       Int!
}

type Mutation {
  createUser(input: CreateUserInput!): User!
}

type Query {
  users(filter: UserFilter!): [User!]!
}

// ── HTTP request body ─────────────────────────────────────────
// POST /graphql
// Content-Type: application/json
// {
//   "query": "mutation Create($input: CreateUserInput!) { createUser(input: $input) { id name } }",
//   "variables": {
//     "input": {
//       "name": "Bob",
//       "email": "bob@example.com",
//       "role": "editor",
//       "metadata": { "plan": "pro", "tags": ["beta"] }
//     }
//   }
// }

// resolvers.ts
const resolvers = {
  Mutation: {
    createUser: async (_parent, args, ctx) => {
      // args.input is fully typed from the SDL:
      // {
      //   name:     string           (coerced from JSON string)
      //   email:    string
      //   role:     string | null    (optional field)
      //   metadata: unknown          (JSON scalar — any JSON value)
      // }
      const { name, email, role, metadata } = args.input
      return ctx.db.users.create({ name, email, role, metadata })
    },
  },

  Query: {
    users: async (_parent, args, ctx) => {
      // args.filter = { nameContains?: string, role?: string,
      //                 minAge?: number, limit: number, offset: number }
      const { nameContains, role, minAge, limit, offset } = args.filter

      return ctx.db.users.findMany({
        where: {
          ...(nameContains && { name: { contains: nameContains } }),
          ...(role          && { role }),
          ...(minAge        && { age: { gte: minAge } }),
        },
        take: limit,
        skip: offset,
      })
    },
  },
}

// ── Variable coercion examples ─────────────────────────────────
// JSON variable  → GraphQL type → resolver receives
// "42"           → ID!          → "42"  (ID is always string in resolvers)
// 42             → Int!         → 42    (number)
// "42.5"         → Float!       → error (string not coerced to Float)
// null           → String       → null  (only if field is nullable)
// ["a","b"]      → [String!]!   → ["a","b"]
// {"key":"val"}  → InputObj!    → { key: "val" }

Input object types are defined in the SDL with the input keyword (not type) and can only be used as argument types — they cannot be returned by resolvers. This separation between input and output types means you can have fields that are writable (in mutations) but not readable, and vice versa. For deeply nested input structures, GraphQL input objects mirror the JSON structure exactly, making them natural to construct on the client side.

The JSON Scalar: Handling Arbitrary JSON Fields

When a field's structure cannot be expressed as a typed GraphQL object — configuration blobs, plugin metadata, user preferences, dynamic form schemas — the JSON scalar from graphql-scalars provides a typed escape hatch. It accepts any JSON value and passes it through the serialization pipeline unchanged. The scalar implements the three required methods: serialize, parseValue, and parseLiteral.

// Install graphql-scalars (v2+)
// npm install graphql-scalars

import { GraphQLJSON, GraphQLJSONObject } from 'graphql-scalars'
import { ApolloServer } from '@apollo/server'
import { startStandaloneServer } from '@apollo/server/standalone'

// ── SDL — add scalar declarations ─────────────────────────────
const typeDefs = `
  scalar JSON
  scalar JSONObject

  type Product {
    id:       ID!
    name:     String!
    price:    Float!
    metadata: JSON       # any JSON value: object, array, string...
    settings: JSONObject # restricted to objects only
  }

  type Query {
    product(id: ID!): Product
  }

  type Mutation {
    updateProductMetadata(id: ID!, metadata: JSON!): Product!
  }
`

// ── Resolvers — register scalar implementations ─────────────
const resolvers = {
  // Register the scalar resolvers — required for graphql-scalars
  JSON:       GraphQLJSON,
  JSONObject: GraphQLJSONObject,

  Query: {
    product: async (_parent, { id }, ctx) => {
      const product = await ctx.db.products.findById(id)
      // product.metadata can be any JSON value from the database
      return product
    },
  },

  Mutation: {
    updateProductMetadata: async (_parent, { id, metadata }, ctx) => {
      // metadata is any JSON value — validated by the JSON scalar
      // but NOT type-checked beyond "is valid JSON"
      await ctx.db.products.update(id, { metadata })
      return ctx.db.products.findById(id)
    },
  },
}

const server = new ApolloServer({ typeDefs, resolvers })

// ── Client query example ───────────────────────────────────────
// POST /graphql
// {
//   "query": "query GetProduct($id: ID!) { product(id: $id) { id name metadata } }",
//   "variables": { "id": "prod-1" }
// }
// Response:
// {
//   "data": {
//     "product": {
//       "id": "prod-1",
//       "name": "Widget",
//       "metadata": {
//         "color": "blue",
//         "dimensions": { "w": 10, "h": 5 },
//         "tags": ["sale", "new"]
//       }
//     }
//   }
// }

// ── Custom scalar (manual approach, without graphql-scalars) ──
import { GraphQLScalarType, Kind } from 'graphql'

const JsonScalar = new GraphQLScalarType({
  name: 'JSON',
  description: 'Arbitrary JSON value',

  // serialize: called when returning a value from a resolver
  serialize(value: unknown) {
    return value  // pass-through — JSON.stringify handles it later
  },

  // parseValue: called when parsing variables from the request body
  parseValue(value: unknown) {
    return value  // the request body is already parsed JSON
  },

  // parseLiteral: called when parsing inline literal values in the SDL
  parseLiteral(ast) {
    if (ast.kind === Kind.STRING) return JSON.parse(ast.value)
    if (ast.kind === Kind.OBJECT) {
      // Convert AST ObjectValue to JS object
      const obj: Record<string, unknown> = {}
      ast.fields.forEach(field => {
        obj[field.name.value] = field.value
      })
      return obj
    }
    return null
  },
})

A common pattern is to store JSON blobs in a PostgreSQL jsonb column and expose them through a JSON scalar field. The database returns the parsed JavaScript object, the resolver returns it unchanged, and the execution engine includes it in the JSON response. No intermediate serialization or deserialization is needed — the JavaScript runtime handles the boundary between the in-memory object and the JSON wire format automatically.

Mutation Input Types and JSON Variables

Mutations use input types to receive structured JSON from the client. The pattern mirrors REST POST bodies: the client sends a JSON variables object, the GraphQL runtime coerces it to the declared input type, and the mutation resolver receives the typed value in args. Input types support nested objects, arrays, nullable fields, and custom scalars including JSON.

// schema.graphql
scalar JSON
scalar DateTime

input AddressInput {
  street: String!
  city:   String!
  zip:    String!
  country: String!
}

input CreateOrderInput {
  customerId: ID!
  items: [OrderItemInput!]!
  shippingAddress: AddressInput!
  metadata: JSON          # arbitrary extra data from the client
  couponCode: String
}

input OrderItemInput {
  productId: ID!
  quantity:  Int!
  price:     Float!
}

type Order {
  id:        ID!
  status:    String!
  total:     Float!
  createdAt: DateTime!
  metadata:  JSON
}

type Mutation {
  createOrder(input: CreateOrderInput!): Order!
  cancelOrder(id: ID!, reason: String): Order!
}

// ── HTTP request body for createOrder mutation ─────────────────
// POST /graphql
// Content-Type: application/json
// {
//   "query": "mutation CreateOrder($input: CreateOrderInput!) { createOrder(input: $input) { id status total } }",
//   "variables": {
//     "input": {
//       "customerId": "cust-99",
//       "items": [
//         { "productId": "prod-1", "quantity": 2, "price": 29.99 },
//         { "productId": "prod-7", "quantity": 1, "price": 99.00 }
//       ],
//       "shippingAddress": {
//         "street": "123 Main St",
//         "city": "Portland",
//         "zip": "97201",
//         "country": "US"
//       },
//       "metadata": { "source": "mobile-app", "promoId": "SUMMER24" },
//       "couponCode": "SAVE10"
//     }
//   }
// }

// resolvers.ts
const resolvers = {
  Mutation: {
    createOrder: async (_parent, args, ctx) => {
      const { customerId, items, shippingAddress, metadata, couponCode } = args.input

      // items is typed as Array<{ productId: string, quantity: number, price: number }>
      const total = items.reduce((sum, item) => sum + item.quantity * item.price, 0)

      const order = await ctx.db.orders.create({
        customerId,
        items,
        shippingAddress,  // nested object, stored as jsonb
        metadata,         // any JSON value
        couponCode,
        total,
        status: 'pending',
      })

      // Emit event for downstream processing
      await ctx.events.publish('order.created', { orderId: order.id, total })

      return order
    },

    cancelOrder: async (_parent, { id, reason }, ctx) => {
      return ctx.db.orders.update(id, {
        status: 'cancelled',
        metadata: { ...await ctx.db.orders.findById(id).metadata, cancellationReason: reason },
      })
    },
  },
}

// ── TypeScript: graphql-codegen generated types ───────────────
// Generated from SDL — run: npx graphql-codegen
type CreateOrderInput = {
  customerId:      string
  items:           OrderItemInput[]
  shippingAddress: AddressInput
  metadata?:       unknown  // JSON scalar → unknown in TypeScript
  couponCode?:     string | null
}

type OrderItemInput = {
  productId: string
  quantity:  number
  price:     number
}

A useful pattern for complex mutations is to accept a single input argument containing all fields, rather than multiple top-level arguments. This follows the "input object pattern" recommended in the GraphQL specification and makes it easier to evolve the API — adding new fields to CreateOrderInput is backward-compatible, whereas adding new top-level arguments to a mutation is not (existing queries that omit the new argument would fail if the field is non-null).

GraphQL Error JSON Format

GraphQL errors follow a precise JSON format regardless of the server library. The HTTP response always uses status 200 (unless the request itself is malformed before GraphQL execution begins). The response body contains a top-level errors array — each entry has message, locations, path, and optionally extensions. Partial success is possible: a response can have both data (partial results) and errors (fields that failed).

// ── Apollo Server 4 error handling ────────────────────────────
import { GraphQLError } from 'graphql'
import { ApolloServerErrorCode } from '@apollo/server/errors'

const resolvers = {
  Query: {
    user: async (_parent, { id }, ctx) => {
      // Authentication check
      if (!ctx.currentUser) {
        throw new GraphQLError('Not authenticated', {
          extensions: {
            code: ApolloServerErrorCode.UNAUTHENTICATED,
            http: { status: 401 },
          },
        })
      }

      // Authorization check
      if (ctx.currentUser.role !== 'admin' && ctx.currentUser.id !== id) {
        throw new GraphQLError('Access denied', {
          extensions: {
            code: ApolloServerErrorCode.FORBIDDEN,
            http: { status: 403 },
          },
        })
      }

      const user = await ctx.db.users.findById(id)

      // Not found
      if (!user) {
        throw new GraphQLError(`User ${id} not found`, {
          extensions: {
            code: 'NOT_FOUND',
            http: { status: 404 },
          },
        })
      }

      return user
    },
  },
}

// ── JSON error response for NOT_FOUND ─────────────────────────
// HTTP 200 — GraphQL errors always use 200 unless pre-execution
// {
//   "data": {
//     "user": null
//   },
//   "errors": [
//     {
//       "message": "User abc not found",
//       "locations": [{ "line": 2, "column": 3 }],
//       "path": ["user"],
//       "extensions": {
//         "code": "NOT_FOUND",
//         "http": { "status": 404 }
//       }
//     }
//   ]
// }

// ── Partial success — some fields resolve, others fail ─────────
// Query: { user { id orders { id total } } }
// user resolver succeeds, orders resolver throws
// {
//   "data": {
//     "user": {
//       "id": "42",
//       "orders": null        ← nulled due to resolver error
//     }
//   },
//   "errors": [
//     {
//       "message": "Database connection timeout",
//       "path": ["user", "orders"],
//       "extensions": { "code": "INTERNAL_SERVER_ERROR" }
//     }
//   ]
// }

// ── Input validation error (BAD_USER_INPUT) ────────────────────
// Query: mutation { createUser(input: { name: "" }) { id } }
// {
//   "errors": [
//     {
//       "message": "Name cannot be empty",
//       "extensions": { "code": "BAD_USER_INPUT", "field": "name" }
//     }
//   ]
// }

// ── formatError — sanitize errors before sending to clients ───
const server = new ApolloServer({
  typeDefs,
  resolvers,
  formatError: (formattedError, error) => {
    // Hide internal error details in production
    if (process.env.NODE_ENV === 'production') {
      if (formattedError.extensions?.code === 'INTERNAL_SERVER_ERROR') {
        return {
          message: 'Internal server error',
          extensions: { code: 'INTERNAL_SERVER_ERROR' },
        }
      }
    }
    return formattedError
  },
})

The extensions.http.status pattern in Apollo Server 4 propagates HTTP status codes from individual GraphQL errors to the HTTP response — useful when using Apollo Server with HTTP clients that check status codes. Without this, clients receive HTTP 200 even for authentication errors. For REST-oriented clients or API gateways that need non-200 status for errors, set httpDriver.cors and use the http extension to communicate the semantic error status.

N+1 Problem and DataLoader Batching

The N+1 problem is GraphQL's most common performance pitfall. When a query fetches a list of N items and each item has a field resolver that makes a separate database query, the total query count is 1 (for the list) + N (one per item) = N+1. For 100 items this means 101 database round-trips. DataLoader coalesces all loads within one event-loop tick into a single batch query, reducing 101 queries to 2.

import DataLoader from 'dataloader'
import { ApolloServer } from '@apollo/server'
import { startStandaloneServer } from '@apollo/server/standalone'

// ── Schema ─────────────────────────────────────────────────────
const typeDefs = `
  type Query {
    posts: [Post!]!
  }
  type Post {
    id:     ID!
    title:  String!
    author: User!       # nested field — N+1 risk without DataLoader
  }
  type User {
    id:   ID!
    name: String!
  }
`

// ── DataLoader batch function ──────────────────────────────────
// Called once per tick with all requested IDs
async function batchLoadUsers(ids: readonly string[]) {
  // Single query for ALL users in one round-trip
  const users = await db.users.findByIds([...ids])

  // DataLoader requires returning values in the SAME ORDER as ids
  const userMap = new Map(users.map(u => [u.id, u]))
  return ids.map(id => userMap.get(id) ?? new Error(`User ${id} not found`))
}

// ── Context factory — create one DataLoader PER REQUEST ───────
// Never share DataLoader instances across requests (data leakage risk)
function createContext() {
  return {
    db,
    // New DataLoader for each request — fresh cache, no cross-request leaks
    userLoader: new DataLoader<string, User>(batchLoadUsers, {
      cache: true,       // deduplicate identical IDs within one request
      maxBatchSize: 100, // cap batch size to avoid huge IN clauses
    }),
  }
}

// ── Resolvers ──────────────────────────────────────────────────
const resolvers = {
  Query: {
    posts: (_parent, _args, ctx) => ctx.db.posts.findAll(),
    // Returns 100 posts — 1 query
  },

  Post: {
    // Called once per Post — but DataLoader batches all 100 load() calls
    author: (post, _args, ctx) => {
      return ctx.userLoader.load(post.authorId)
      // First call: schedules a load for authorId, returns a Promise
      // After all 100 Post.author resolvers run in the same tick:
      // DataLoader fires batchLoadUsers(["u1","u2",...,"u37"])
      // 100 resolvers → 1 batch query = 2 total queries (posts + users)
    },
  },
}

// ── Apollo Server setup ────────────────────────────────────────
const server = new ApolloServer({ typeDefs, resolvers })

const { url } = await startStandaloneServer(server, {
  context: async () => createContext(), // fresh context (and DataLoader) per request
  listen: { port: 4000 },
})

// ── Without DataLoader (N+1 example) ──────────────────────────
// Post: {
//   author: async (post, _args, ctx) => {
//     return ctx.db.users.findById(post.authorId) // 100 separate queries!
//   },
// }
// 100 posts × 1 query each = 101 queries total vs 2 with DataLoader

// ── DataLoader with graphql-yoga ──────────────────────────────
import { createSchema, createYoga } from 'graphql-yoga'
import { createServer } from 'http'

const yoga = createYoga({
  schema: createSchema({ typeDefs, resolvers }),
  context: () => createContext(), // same pattern
})

const httpServer = createServer(yoga)
httpServer.listen(4000)

DataLoader also handles error cases gracefully: if the batch function returns an Error instance for a specific ID (rather than a value), DataLoader rejects the Promise for that individual load while fulfilling the others. This allows partial failures — if 3 out of 100 user IDs do not exist in the database, those 3 Post.author fields return errors while the remaining 97 succeed, enabling the partial success pattern in the GraphQL error JSON response.

Apollo Server 4: JSON Request and Response Format

Apollo Server 4 implements the GraphQL over HTTP specification. Every request is a JSON POST body; every response is a JSON body with Content-Type: application/json. Apollo Server 4 dropped many legacy APIs from v3 — the Express integration now uses expressMiddleware rather than applyMiddleware, and context is defined per-integration rather than on the server constructor.

import { ApolloServer } from '@apollo/server'
import { expressMiddleware } from '@apollo/server/express4'
import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer'
import express from 'express'
import http from 'http'
import cors from 'cors'
import bodyParser from 'body-parser'

const app = express()
const httpServer = http.createServer(app)

const server = new ApolloServer({
  typeDefs,
  resolvers,
  plugins: [ApolloServerPluginDrainHttpServer({ httpServer })],
})

await server.start()

app.use(
  '/graphql',
  cors<cors.CorsRequest>(),
  bodyParser.json(),          // parse application/json request bodies
  expressMiddleware(server, {
    context: async ({ req }) => ({
      db:          createDbClient(),
      currentUser: await authenticate(req.headers.authorization),
      userLoader:  new DataLoader(batchLoadUsers),
    }),
  }),
)

await new Promise<void>((resolve) => httpServer.listen({ port: 4000 }, resolve))

// ── Valid GraphQL over HTTP request formats ────────────────────

// 1. Simple query (no variables)
// POST /graphql
// { "query": "{ users { id name } }" }

// 2. Named query with variables
// POST /graphql
// {
//   "query": "query GetUser($id: ID!) { user(id: $id) { id name email } }",
//   "variables": { "id": "42" },
//   "operationName": "GetUser"
// }

// 3. Mutation with input
// POST /graphql
// {
//   "query": "mutation CreateUser($input: CreateUserInput!) { createUser(input: $input) { id } }",
//   "variables": { "input": { "name": "Alice", "email": "alice@example.com" } }
// }

// ── Response envelope ──────────────────────────────────────────
// Successful response:
// HTTP 200
// Content-Type: application/json
// {
//   "data": {
//     "user": { "id": "42", "name": "Alice", "email": "alice@example.com" }
//   }
// }

// Error response (validation error before execution):
// HTTP 400
// Content-Type: application/json
// {
//   "errors": [
//     {
//       "message": "Cannot query field 'nonexistent' on type 'User'.",
//       "locations": [{ "line": 1, "column": 10 }],
//       "extensions": { "code": "GRAPHQL_VALIDATION_FAILED" }
//     }
//   ]
// }

// ── graphql-yoga equivalent ────────────────────────────────────
import { createYoga, createSchema } from 'graphql-yoga'
import { createServer } from 'http'

const yoga = createYoga({
  schema: createSchema({ typeDefs, resolvers }),
  context: ({ request }) => ({
    db:          createDbClient(),
    currentUser: authenticate(request.headers.get('authorization') ?? ''),
    userLoader:  new DataLoader(batchLoadUsers),
  }),
})

// graphql-yoga works as a fetch handler — compatible with Node.js,
// Cloudflare Workers, Bun, Deno, and AWS Lambda without adapters
createServer(yoga).listen(4000)

// ── TypeScript: full type-safe resolver with graphql-codegen ──
// codegen.ts
// import type { CodegenConfig } from '@graphql-codegen/cli'
// const config: CodegenConfig = {
//   schema: './schema.graphql',
//   generates: {
//     './src/generated/resolvers.ts': {
//       plugins: ['typescript', 'typescript-resolvers'],
//       config: {
//         contextType: '../context#MyContext',
//         scalars: { JSON: 'unknown', DateTime: 'Date' },
//       },
//     },
//   },
// }
// export default config

Apollo Server 4 and graphql-yoga both implement the Incremental Delivery (@defer and @stream) extensions from the GraphQL spec draft, allowing large response arrays to be streamed as multipart JSON chunks. This is useful when a query returns 1000 items — the client can start rendering the first 50 items while the remaining 950 are still being resolved server-side, improving perceived latency without pagination round-trips.

Key Terms

resolver
A resolver is a function that returns the value for a single field in a GraphQL schema. Its signature is (parent, args, context, info) => value | Promise<value>. The parent is the object returned by the parent field's resolver; args is the parsed JSON of the field's arguments; context is a shared request-scoped object (database client, authenticated user, DataLoader instances); info contains schema metadata and the query AST. The GraphQL execution engine calls resolvers recursively and serializes their return values to JSON matching the client's selection set. Resolvers never call JSON.stringify() directly.
JSON scalar
A GraphQL scalar type that accepts and returns any valid JSON value — object, array, string, number, boolean, or null. Defined by three methods: serialize(output) converts a resolver return value to a JSON-compatible value; parseValue(input) validates a variable value from the request body; parseLiteral(ast) parses an inline literal in the query SDL. The JSON and JSONObject scalars from graphql-scalars are production-ready implementations. Use JSON scalar for unstructured metadata fields, configuration blobs, or extension fields whose shape cannot be expressed as a typed GraphQL object. Overuse removes type safety and makes GraphQL introspection less useful.
GraphQL variables
Variables are the JSON object sent in the variables key of a GraphQL HTTP request body. They are declared in the operation definition with a $ prefix and a type annotation: query GetUser($id: ID!). The GraphQL runtime validates variables against their declared types and coerces JSON values before resolver execution — resolvers receive typed values, not raw JSON strings. Variables are the correct way to pass dynamic data to GraphQL operations; embedding values directly in the query string prevents server-side query caching and is a security risk for string injection.
DataLoader
DataLoader is a utility that batches and caches individual load requests within a single event-loop tick. A batch function receives all keys collected during one tick and returns a Promise resolving to an array of values in the same order. DataLoader deduplicates identical keys (cache: true by default) and coalesces N individual load calls into a single batch call, reducing N database queries to 1. In GraphQL, create a new DataLoader instance inside the context factory function (called once per request) to ensure fresh per-request caches. The batch function maps to a SELECT WHERE id IN (...) query, REST API batch endpoint, or any async data source.
selection set
The selection set is the set of fields the client requests in a GraphQL query, enclosed in curly braces: { id name email }. The GraphQL execution engine uses the selection set to determine which field resolvers to call and which fields to include in the JSON response. Fields not in the selection set are never resolved or serialized, even if the parent object returned by a resolver contains them. This is how GraphQL prevents over-fetching — a client requesting 3 fields from a type with 20 fields only receives 3 fields in the JSON response, and only those 3 resolver functions execute.
N+1 problem
The N+1 problem occurs when a GraphQL query fetches a list of N parent objects and each parent's nested field resolver fires a separate database query. Fetching 100 posts with each post's author field causes 1 query for the posts list plus 100 queries for individual authors = 101 total queries. The standard solution is DataLoader, which batches all 100 userLoader.load(id) calls within one event-loop tick into a single SELECT WHERE id IN (...) query, reducing 101 queries to 2. Without DataLoader, N+1 is the leading cause of GraphQL API performance issues in production.

FAQ

How does a GraphQL resolver return JSON?

A GraphQL resolver returns a plain JavaScript object (or a Promise resolving to one). The GraphQL execution engine — not the resolver — serializes that object to JSON, keeping only the fields the client requested in the selection set. If a query asks for { user { id name } }, the engine serializes only id and name from the resolver's return value, even if the object has 20 additional fields. The final HTTP response body is application/json with a top-level data key: {"data":{"user":{"id":"1","name":"Alice"}}}. Resolvers never call JSON.stringify() directly — they simply return values, and the runtime handles serialization. In Apollo Server 4 and graphql-yoga, a scalar resolver can return any JavaScript value that the scalar's serialize() method can convert; for the 5 built-in scalars (String, Int, Float, Boolean, ID) the serialization rules are defined by the GraphQL specification.

What is the JSON scalar type in GraphQL?

The JSON scalar from the graphql-scalars package (version 14+) represents any valid JSON value — object, array, string, number, boolean, or null — without a fixed schema. It is defined with 3 methods: serialize() converts the output value to JSON, parseValue() validates inline literal values, and parseLiteral() handles values in SDL literals. The scalar is added to a schema by importing GraphQLJSON and placing it in the resolvers map under the "JSON" key alongside a scalar JSON declaration in the SDL. A field typed as JSON accepts and returns any JSON structure, useful for metadata, settings, or extension fields where the shape is not known at schema-definition time. The graphql-scalars package ships over 60 production-ready scalars. Using JSON scalar too broadly removes type safety and makes introspection less useful — reserve it for genuinely unstructured blobs.

How do GraphQL variables work as JSON?

GraphQL variables are sent as the "variables" key in the JSON request body over HTTP POST. The full request body is: {"query": "query GetUser($id: ID!) { user(id: $id) { name } }", "variables": {"id": "42"}}. The server parses the variables JSON object and coerces each value to the declared GraphQL type — "42" coerces to ID, true stays as Boolean, numeric values coerce to Int or Float. Variable types are declared in the operation definition (e.g., $id: ID!) and the variables object is validated against those types before resolver execution begins. If a required variable is missing or has the wrong type, the server returns an errors array without calling any resolver. Variables are the correct way to pass dynamic values — embedding values in the query string prevents server-side query caching (persisted queries) and is a security risk.

How do I handle arbitrary JSON objects in a GraphQL schema?

Use the JSON or JSONObject scalar from graphql-scalars for fields whose structure cannot be expressed as a typed GraphQL object. Install the package: npm install graphql-scalars. Import GraphQLJSON and GraphQLJSONObject, add scalar JSON and scalar JSONObject to your type definitions, and add them to your resolvers map: { JSON: GraphQLJSON, JSONObject: GraphQLJSONObject }. Then type any field as JSON: type Product {'{ id: ID!, metadata: JSON }'}. The metadata field accepts any JSON value from the client and returns any serializable value from the resolver. For TypeScript, graphql-scalars exports JsonValue and JsonObject types. Limit JSONscalar fields to 1–3 per schema — each one is opaque to clients and tools like Apollo Studio Explorer and code generators, reducing the value of GraphQL's type system.

What does a GraphQL error JSON response look like?

A GraphQL error response always uses HTTP 200 (unless the request is malformed before execution) and includes an errors array at the top level of the JSON body. A typical error looks like: {"data": null, "errors": [{"message": "User not found", "locations": [{"line": 2, "column": 3}], "path": ["user"], "extensions": {"code": "NOT_FOUND", "http": {"status": 404}}}]}. The errors array entries each have: message (required string), locations (array of query position objects), path (array of field names from root to the failing field), and extensions (optional object for machine-readable codes). When a partial response is possible, both data (partial results) and errors are present simultaneously. Apollo Server 4 uses ApolloServerErrorCode enum for built-in codes: UNAUTHENTICATED, FORBIDDEN, NOT_FOUND, BAD_USER_INPUT, GRAPHQL_VALIDATION_FAILED.

How do I send a GraphQL query as JSON over HTTP?

Send an HTTP POST request with Content-Type: application/json and a body containing the query string, optional variables object, and optional operationName. Example with fetch: fetch("/graphql", {'{ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query: "query GetUser($id: ID!) { user(id: $id) { name email } }", variables: { id: "42" } }) }'}). The server responds with Content-Type: application/json and a body with a data key on success and/or errors array on failure. Both Apollo Server 4 and graphql-yoga accept this format. For GET requests, the query is URL-encoded in the query query string parameter — but GET requests cannot send complex variables safely and are not suitable for mutations. The operationName field selects which operation to run when the document contains multiple named operations. Never interpolate variables directly into the query string — always use the variables object to prevent injection and enable server-side caching.

How does DataLoader solve the N+1 JSON query problem?

The N+1 problem occurs when a list resolver returns 100 objects and each object's field resolver fires an individual database query — 1 query for the list plus 100 queries for the nested field, totalling 101 queries. DataLoader (npm install dataloader) solves this by batching all requests within the same event-loop tick into a single query. You define a batch function that receives an array of keys and returns a Promise resolving to an array of values in the same order. The DataLoader instance deduplicates identical keys (cache: true by default) and coalesces calls across all resolvers executing in parallel. In practice: create a DataLoader per request (not per server start) inside Apollo Server's context function. A user field resolver calls userLoader.load(id) — 100 calls in one tick become 1 batched query: SELECT * FROM users WHERE id IN (1,2,...,100). Execution drops from 101 queries to 2, reducing latency from ~500ms to ~10ms for a typical PostgreSQL setup.

How do I type GraphQL resolver return values in TypeScript?

Use graphql-codegen (npm install @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-resolvers) to generate TypeScript types from your schema SDL. Running codegen produces a Resolvers type where each field resolver is fully typed — parent, args, context, and return value are all inferred from the SDL. Add a codegen.ts config file pointing at your schema and output path, then run npx graphql-codegen. Import the generated Resolvers type and apply it to your resolvers object: const resolvers: Resolvers = {'{ Query: { user: (_, args, ctx) => ctx.db.findUser(args.id) } }'}. The args parameter is typed to match the SDL argument definitions. For the JSON scalar, configure scalars: {'{ JSON: "JsonValue" }'} in codegen.ts and import JsonValue from graphql-scalars. This gives end-to-end TypeScript safety across the full resolver stack — over 90% of type errors in GraphQL APIs are caught at compile time with this setup.

Validate your GraphQL JSON responses instantly

Paste a GraphQL JSON response body into Jsonic's formatter and validator to check structure, spot nulls, and explore the data envelope.

Open JSON Validator

Further reading and primary sources