GraphQL API Design: Schema-First Development with TypeScript | SoniNow Blog

Limited TimeLearn More

graphqlapitypescriptschema designbackend

GraphQL API Design: Schema-First Development with TypeScript

Published

2026-06-23

Read Time

4 mins

GraphQL API Design: Schema-First Development with TypeScript

GraphQL gives clients exactly the data they ask for — no more, no less. Schema-first development means you define your API contract in SDL (Schema Definition Language) first, then build resolvers that implement it. This approach keeps your API consistent, well-documented, and type-safe across the entire stack.

Defining the Schema First

Start with your GraphQL schema. This becomes the single source of truth for your API contract. Every client, every resolver, every type generation script references this file.

# schema.graphql
type Product {
  id: ID!
  title: String!
  description: String
  price: Float!
  category: Category
  tags: [String!]!
  createdAt: DateTime!
}

type Category {
  id: ID!
  name: String!
  products: [Product!]!
}

type Query {
  products(filter: ProductFilter, page: Int, limit: Int): ProductConnection!
  product(id: ID!): Product
  categories: [Category!]!
}

type Mutation {
  createProduct(input: CreateProductInput!): Product!
  updateProduct(id: ID!, input: UpdateProductInput!): Product!
  deleteProduct(id: ID!): Boolean!
}

type ProductConnection {
  edges: [ProductEdge!]!
  pageInfo: PageInfo!
}

type ProductEdge {
  node: Product!
  cursor: String!
}

type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}

Design the schema around business capabilities, not database tables. A Product type should represent what a product means in your domain, not mirror a database row. This decoupling lets you change the underlying storage without breaking clients.

Type Generation from Schema

Use GraphQL Code Generator to produce TypeScript types directly from your schema. This eliminates the need to maintain separate type definitions.

# codegen.yml
schema: './src/graphql/schema.graphql'
generates:
  ./src/graphql/__generated__/types.ts:
    plugins:
      - typescript
      - typescript-resolvers
    config:
      contextType: ../context#GraphQLContext
      mapperTypeSuffix: Model
      mappers:
        Product: '@prisma/client#Product'
        Category: '@prisma/client#Category'
// Generated resolvers are fully typed
import type { Resolvers } from './__generated__/types'

export const resolvers: Resolvers = {
  Query: {
    products: async (_, args, context) => {
      const { filter, page, limit } = args
      // context.prisma is fully typed
      const products = await context.prisma.product.findMany({
        where: { category: filter?.category },
        take: limit ?? 20,
        skip: ((page ?? 1) - 1) * (limit ?? 20),
      })
      return {
        edges: products.map((product) => ({
          node: product,
          cursor: Buffer.from(product.id).toString('base64'),
        })),
        pageInfo: {
          hasNextPage: products.length === (limit ?? 20),
          startCursor: null,
          endCursor: null,
        },
      }
    },
  },
}

The generator ensures resolver arguments, return types, and context are always in sync with the schema. A schema change without updating resolvers produces a type error at build time.

Error Handling Beyond HTTP Status Codes

GraphQL always returns 200 — errors appear in the errors array of the response. Use a standardized error format and custom error types for the client to handle.

import { GraphQLError } from 'graphql'

export class NotFoundError extends GraphQLError {
  constructor(resource: string, id: string) {
    super(`${resource} with id ${id} not found`, {
      extensions: {
        code: 'NOT_FOUND',
        resource,
        id,
      },
    })
  }
}

export class UnauthorizedError extends GraphQLError {
  constructor(message = 'Authentication required') {
    super(message, {
      extensions: { code: 'UNAUTHORIZED' },
    })
  }
}

// In resolver
Query: {
  product: async (_, { id }, context) => {
    const product = await context.prisma.product.findUnique({ where: { id } })
    if (!product) throw new NotFoundError('Product', id)
    return product
  },
}

Clients can then switch on extensions.code to show appropriate UI — a "not found" state, a login redirect, or a validation error summary.

Pagination with Cursors

Offset pagination (page/limit) breaks when data changes between requests. Cursor-based pagination uses a stable identifier so the client can paginate consistently.

async function paginateProducts(cursor?: string, limit: number = 20) {
  const products = await context.prisma.product.findMany({
    take: limit + 1,
    ...(cursor && {
      cursor: { id: Buffer.from(cursor, 'base64').toString() },
      skip: 1,
    }),
    orderBy: { createdAt: 'desc' },
  })

  const hasNextPage = products.length > limit
  const nodes = hasNextPage ? products.slice(0, limit) : products

  return {
    edges: nodes.map((node) => ({
      node,
      cursor: Buffer.from(node.id).toString('base64'),
    })),
    pageInfo: {
      hasNextPage,
      endCursor: nodes.length > 0
        ? Buffer.from(nodes[nodes.length - 1].id).toString('base64')
        : null,
    },
  }
}

Subscriptions for Real-Time Data

GraphQL subscriptions use WebSockets to push data to clients. Use them for real-time updates that benefit from GraphQL's field-level selection.

type Subscription {
  productUpdated: Product!
  orderStatusChanged(orderId: ID!): Order!
}

Publish events from your mutation resolvers using a PubSub system. For production, use Redis-based PubSub rather than in-memory to support multiple server instances.

Schema-first GraphQL development with TypeScript gives you a type-safe, discoverable, well-documented API that clients love to consume. At SoniNow, we design and build GraphQL APIs that scale from internal tools to B2B platforms with hundreds of consuming applications.

Starting a GraphQL project? Reach out to SoniNow for schema design and API architecture services.