Practical GraphQL APIs: Schema-First Design with TypeScript
#graphql
#typescript
#schema-first
#webdev
#tutorial
Introduction
Schema-first design is a pragmatic approach for building robust GraphQL APIs. By starting with a clear, centralized SDL (Schema Definition Language), you define the surface area of your API before implementing business logic. This aligns teams around a single contract and enables better collaboration between frontend and backend developers. When paired with TypeScript, you get strong type safety across your resolvers, tests, and UI codegen, reducing runtime surprises.
Why schema-first with TypeScript
- Clear API contracts: The schema serves as the single source of truth people can rely on.
- Strong typing via tooling: Generate TypeScript types from your schema to catch mismatches early.
- Incremental adoption: Start with a minimal schema and evolve it with deprecation and migrations.
- Better tooling ecosystem: SDL-first work pairs well with GraphQL tooling like Apollo Server, GraphQL Tools, and codegen.
Defining your GraphQL schema with SDL
Begin with a concise SDL file that models your domain. Keep concerns separated by features and avoid deep nesting in a single file. Here’s a small example to illustrate a blog-like domain:
# src/schema.graphql
type Query {
post(id: ID!): Post
posts: [Post!]!
}
type Post {
id: ID!
title: String!
content: String
author: User!
}
type User {
id: ID!
name: String!
}
In this SDL, you define the API surface without tying it to concrete data sources or business logic yet. This SDL is the contract that frontend teams can rely on.
Wiring SDL to resolvers in TypeScript
With a schema in place, implement resolvers in TypeScript. Use a GraphQL schema builder like graphql-tools to combine your SDL with resolvers, then create a running server.
// src/resolvers.ts
import { IResolvers } from '@graphql-tools/utils';
type Post = { id: string; title: string; content?: string; authorId: string };
type User = { id: string; name: string };
const posts: Post[] = [
{ id: 'p1', title: 'Intro to Schema-First', content: '...', authorId: 'u1' },
// ...
];
const users: User[] = [
{ id: 'u1', name: 'Alice' },
// ...
];
export const resolvers: IResolvers = {
Query: {
post: (_: any, { id }: { id: string }) => posts.find((p) => p.id === id) ?? null,
posts: () => posts,
},
Post: {
author: (post: Post) => users.find((u) => u.id === post.authorId)!,
},
};
// src/server.ts
import { makeExecutableSchema } from '@graphql-tools/schema';
import { graphqlHTTP } from 'express-graphql';
import express from 'express';
import typeDefs from './schema.graphql';
import { resolvers } from './resolvers';
const schema = makeExecutableSchema({ typeDefs, resolvers });
const app = express();
app.use('/graphql', graphqlHTTP({ schema, graphiql: true }));
app.listen(4000, () => console.log('GraphQL API ready at http://localhost:4000/graphql'));
Note: Depending on your setup, you may prefer a resolver-first approach with code generation for types. The key idea in schema-first is to keep the SDL as the source of truth and implement resolvers against that contract.
Tooling: from SDL to TypeScript types
To avoid drift between your SDL and TypeScript code, use GraphQL Code Generator to produce precise TS types and resolver scaffolding from your schema.
Example codegen config (codegen.yml):
schema: ./src/schema.graphql
generates:
./src/types/graphql.ts:
plugins:
- typescript
- typescript-resolvers
How to wire it up:
- Run: npm run generate (assuming you’ve added a script)
- Use the generated types in your resolvers to ensure you implement the expected shape.
This approach gives you:
- Strongly-typed resolver signatures
- Shared types between server and client
- Safer refactors when the schema evolves
Example: a small GraphQL API for a blog
- SDL: src/schema.graphql (as shown above)
- TS resolvers: src/resolvers.ts
- Codegen: generates src/types/graphql.ts
- Server: src/server.ts
End-to-end flow:
- Frontend requests data according to the SDL contract.
- Backend enforces the contract via strongly-typed resolvers.
- Type generation helps keep frontend and backend aligned as the API evolves.
Performance, security, and best practices
- Reduce N+1 queries with data loaders: fetch related data in batched requests in a single pass.
- Context-aware authorization: carry user information in the GraphQL context and enforce permissions in resolvers.
- Schema modularity: split large schemas into feature modules and use schema stitching or federation if you scale across services.
- Validation and error handling: use custom scalar types for strict input validation and standardized error formats.
- Documentation and experiments: keep SDL well-documented with descriptions on types and fields to help frontend consumers.
Testing and validation
- Unit tests for resolvers: mock data sources and verify resolver outputs against the SDL contract.
- Integration tests: spin up a test GraphQL server and run queries against it.
- Snapshot testing for responses: guard against accidental changes to the schema’s output shape.
- Type-safe tests: run codegen and verify that resolver signatures remain compatible with the generated types.
Conclusion
A schema-first approach with TypeScript helps teams maintain a clean contract between frontend and backend while still benefiting from TypeScript’s type safety. By starting with SDL, wiring resolvers in TS, and leveraging code generation, you get a resilient, scalable, and well-typed GraphQL API that supports growth and iteration.