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.
Related Insights

API Rate Limiting Strategies: Token Bucket, Leaky Bucket, and Sliding Window
A guide to implementing API rate limiting including token bucket, leaky bucket, sliding window, and distributed rate limiting with Redis for production APIs.

API Security Best Practices: Authentication, Rate Limiting, and Input Validation
Best practices for securing APIs including API key management, OAuth token validation, rate limiting, input sanitization, CORS configuration, and request signing.

CORS Configuration: Cross-Origin Resource Sharing Done Safely
Learn CORS configuration including allowed origins, methods, headers, credentials, preflight requests, and common security pitfalls when exposing APIs.