GraphQL Guide
GraphQL Guide
Section titled “GraphQL Guide”Use the GraphQL plugin and decorators with Veloce-TS. Queries and mutations (@Resolver, @GQLQuery, @GQLMutation, @Arg) execute end-to-end as of v2.0.0 — earlier releases advertised this but did not actually invoke your resolvers, see Return types. Subscriptions execute as of v3.4.0 over graphql-transport-ws — see Subscriptions.
Requires the
graphqlpeer dependency:Terminal window bun add graphql
Table of Contents
Section titled “Table of Contents”Overview
Section titled “Overview”Veloce-TS provides comprehensive GraphQL support with:
- Automatic Schema Generation from decorator metadata
- Decorator-based Resolvers for clean, declarative code
- Zod Integration for argument validation and type safety
- Type-safe Arguments with automatic validation
- Subscriptions over WebSocket using the
graphql-transport-wssubprotocol - Playground & Introspection for development
1. Define resolver classes
Section titled “1. Define resolver classes”import { Resolver, GQLQuery, GQLMutation, Arg } from 'veloce-ts/graphql';import { z } from 'zod';
@Resolver()class UserResolver { @GQLQuery('getUser') async getUser(@Arg('id', z.string()) id: string) { return { id, name: 'Alice' }; }
@GQLMutation('createUser') async createUser(@Arg('name', z.string()) name: string) { return { id: '1', name }; }}2. Register the GraphQL plugin
Section titled “2. Register the GraphQL plugin”Option A — pass resolvers directly to the plugin (recommended):
import { Veloce, GraphQLPlugin } from 'veloce-ts';
const app = new Veloce();
app.usePlugin(new GraphQLPlugin({ path: '/graphql', playground: true, resolvers: [UserResolver], // all @Resolver classes go here}));
await app.compile();app.listen(3000);Option B — use app.include() before the plugin:
app.include(UserResolver); // recognized as a @Resolver classapp.usePlugin(new GraphQLPlugin({ playground: true }));Both options can be combined; resolvers are deduplicated automatically.
3. GraphQLPlugin options
Section titled “3. GraphQLPlugin options”| Option | Default | Description |
|---|---|---|
path | '/graphql' | HTTP path for the GraphQL endpoint |
playgroundPath | '/graphql/playground' | Path for GraphQL Playground |
playground | true | Enable/disable the Playground UI |
resolvers | [] | Resolver classes to include in the schema |
context | (req) => ({ request: req }) | Custom context factory (async allowed) |
subscriptions | false | Serve subscriptions over WebSocket. true for the defaults, or an object — see Subscriptions |
Use app.useCors(...) or global middleware for CORS—it is not configured on the plugin object.
Resolvers
Section titled “Resolvers”Basic Resolver
Section titled “Basic Resolver”import { Resolver, GQLQuery, Arg } from 'veloce-ts/graphql';import { z } from 'zod';
const UserSchema = z.object({ id: z.string(), name: z.string(), email: z.string().email()});
@Resolver()export class UserResolver {
@GQLQuery() async users(): Promise<User[]> { return await userService.findAll(); }
@GQLQuery() async user(@Arg('id', z.string()) id: string): Promise<User | null> { return await userService.findById(id); }
@GQLQuery() async userByName(@Arg('name', z.string()) name: string): Promise<User[]> { return await userService.findByName(name); }}Mutations
Section titled “Mutations”import { GQLMutation, Arg } from 'veloce-ts/graphql';
const CreateUserSchema = z.object({ name: z.string().min(1), email: z.string().email(), password: z.string().min(6)});
@Resolver()export class UserResolver {
@GQLMutation() async createUser(@Arg('input', CreateUserSchema) input: z.infer<typeof CreateUserSchema>): Promise<User> { const hashedPassword = await bcrypt.hash(input.password, 10);
const user = await userService.create({ ...input, password: hashedPassword });
return user; }
@GQLMutation() async updateUser( @Arg('id', z.string()) id: string, @Arg('input', CreateUserSchema.partial()) input: Partial<z.infer<typeof CreateUserSchema>> ): Promise<User> { return await userService.update(id, input); }
@GQLMutation() async deleteUser(@Arg('id', z.string()) id: string): Promise<boolean> { await userService.delete(id); return true; }}Subscriptions
Section titled “Subscriptions”A subscription resolver returns an async iterable. PubSub is the usual source, and a plain async generator works too.
import { Resolver, GQLSubscription, GQLMutation, Arg, Returns, PubSub } from 'veloce-ts';
export const pubsub = new PubSub();
@Resolver()export class ChatResolver { @GQLSubscription() @Returns(MessageSchema, { name: 'ChatMessage' }) messageAdded() { return pubsub.subscribe('MESSAGE_ADDED'); }
@GQLMutation() @Returns(MessageSchema, { name: 'ChatMessage' }) async sendMessage(@Arg('input', SendMessageSchema) input: SendMessage) { const message = await db.insert(input); await pubsub.publish('MESSAGE_ADDED', message); return message; }}Publishing the field value directly is the common case, so what you publish is what the client receives: pubsub.publish('MESSAGE_ADDED', message) arrives as { data: { messageAdded: message } }.
Enabling the transport
Section titled “Enabling the transport”Off by default — opening a WebSocket endpoint should be a deliberate choice.
app.usePlugin(new GraphQLPlugin({ resolvers: [ChatResolver], subscriptions: true, // or an options object}));The endpoint shares the GraphQL path, which is what clients assume: an upgrade request and a POST can coexist there because they are different methods.
| Option | Default | Description |
|---|---|---|
path | the GraphQL path | Where the WebSocket endpoint listens |
onConnect | — | Gate the connection on connection_init. false refuses it (close 4403); an object is sent back as the connection_ack payload |
context | — | Build the context resolvers receive, once per connection |
connectionInitWaitTimeout | 3000 | How long a client may take to send connection_init before being closed with 4408 |
Authenticating a subscription
Section titled “Authenticating a subscription”A browser WebSocket cannot send an Authorization header, so credentials travel in connectionParams:
subscriptions: { onConnect: ({ connectionParams }) => { const user = verifyToken(connectionParams?.token as string); return Boolean(user); }, context: ({ connectionParams }) => ({ user: verifyToken(connectionParams?.token as string), }),}// clientcreateClient({ url: 'ws://localhost:3000/graphql', connectionParams: { token: localStorage.getItem('token') },});PubSub
Section titled “PubSub”const pubsub = new PubSub();
await pubsub.publish('TOPIC', payload); // fan out to every subscriberpubsub.subscribe('TOPIC'); // AsyncIterableIteratorpubsub.subscribe(['ORDERS', 'REFUNDS']); // several topics at oncepubsub.subscriberCount('TOPIC');pubsub.close('TOPIC'); // end every subscriberIt is built on EventBus, so passing the shared bus makes an @On listener and a GraphQL subscription react to the same publish:
import { globalEvents } from 'veloce-ts';
const pubsub = new PubSub({ eventBus: globalEvents });Backpressure. A subscriber that cannot keep up would otherwise grow an unbounded queue. maxQueueSize (default 1000) caps it, and backpressure decides what gives: 'drop-oldest' (default, keeps the newest state), 'drop-newest' (keeps the oldest, preserving order from the start), or 'error' (ends that subscriber with a SubscriberOverflowError).
Requirements and runtime notes
Section titled “Requirements and runtime notes”- The
graphqlpackage is required, as an optional peer dependency:npm install graphql. - On Node the endpoint also needs
@hono/node-ws, and the app must be served withapp.listen()— the WebSocket handler attaches to the realhttp.Server, which does not exist until then. - Bun and Deno upgrade natively and need no extra package.
- A
WebSocketPlugingateway and the subscription endpoint can run on the same server.
Cleaning up
Section titled “Cleaning up”Ending an operation, a dropped socket and app.shutdown() all run the iterator’s teardown, which detaches its bus listener. Nothing is left attached after a client goes away.
Close codes
Section titled “Close codes”| Code | Meaning |
|---|---|
| 4400 | Bad request — malformed message, unknown type, missing id/query |
| 4401 | subscribe arrived before connection_init was acknowledged |
| 4403 | onConnect refused the connection |
| 4408 | connection_init never arrived in time |
| 4409 | An operation with that id is already running |
| 4429 | More than one connection_init |
Return types
Section titled “Return types”A field’s GraphQL type is resolved in this order:
- an explicit return type in the decorator metadata, if present;
- the
@Returns()decorator; - otherwise a nullable
JSONscalar, as a documented fallback.
import { Resolver, GQLQuery, GQLMutation, Arg } from 'veloce-ts';import { Returns } from 'veloce-ts/graphql';import { z } from 'zod';
const UserSchema = z.object({ id: z.string(), name: z.string(), email: z.string().email(),});
@Resolver()class UserResolver { // Emits `type User { id: String!, name: String!, email: String! }` @GQLQuery() @Returns(UserSchema, { name: 'User' }) async user(@Arg('id') id: string) { return userService.findById(id); }
// A list: `[User!]!` @GQLQuery() @Returns(UserSchema, { name: 'User', list: true }) async users() { return userService.findAll(); }
// A built-in scalar by name @GQLQuery() @Returns('Int') async userCount() { return userService.count(); }
// Nullable — emitted without the trailing `!` @GQLQuery() @Returns(UserSchema, { name: 'User', nullable: true }) async maybeUser(@Arg('id') id: string) { return userService.findById(id) ?? null; }}ReturnsOptions:
| Option | Meaning |
|---|---|
name | Object type name to emit for a ZodObject. Defaults to the capitalized field name. |
nullable | true drops the !. Default false. |
list | true wraps the type as a list. |
Zod-typed arguments generate named input types automatically, so @Arg('input', SomeZodObject) produces a proper input type rather than a scalar.
Decorators
Section titled “Decorators”All GraphQL decorators are imported from 'veloce-ts/graphql' (also re-exported from 'veloce-ts').
GraphQL Decorators
Section titled “GraphQL Decorators”| Decorator | Purpose | Example |
|---|---|---|
@Resolver(name?) | Marks a class as a GraphQL resolver | @Resolver('user') |
@GQLQuery(name?) | Defines a GraphQL query | @GQLQuery('getUser') |
@GQLMutation(name?) | Defines a GraphQL mutation | @GQLMutation('createUser') |
@GQLSubscription(name?) | Defines a GraphQL subscription | @GQLSubscription() |
@Arg(name, schema?) | Defines GraphQL arguments with optional Zod validation | @Arg('id', z.string()) |
@GQLContext() | Injects the GraphQL context object | @GQLContext() ctx: any |
Advanced Argument Handling
Section titled “Advanced Argument Handling”import { Arg } from 'veloce-ts/graphql';
const PaginationSchema = z.object({ page: z.number().min(1).default(1), limit: z.number().min(1).max(100).default(10), sortBy: z.enum(['name', 'email', 'createdAt']).default('createdAt'), sortOrder: z.enum(['asc', 'desc']).default('desc')});
const FilterSchema = z.object({ name: z.string().optional(), email: z.string().email().optional(), role: z.enum(['admin', 'user', 'guest']).optional()});
@Resolver()export class UserResolver {
@GQLQuery() async users( @Arg('pagination', PaginationSchema) pagination: z.infer<typeof PaginationSchema>, @Arg('filters', FilterSchema) filters: z.infer<typeof FilterSchema> ): Promise<{ users: User[]; total: number; page: number }> { const result = await userService.findWithPagination(pagination, filters);
return { users: result.users, total: result.total, page: pagination.page }; }}Schema Generation
Section titled “Schema Generation”Automatic Type Conversion
Section titled “Automatic Type Conversion”Veloce-TS automatically converts Zod schemas to GraphQL types:
// Zod Schemaconst UserSchema = z.object({ id: z.string(), name: z.string(), email: z.string().email(), age: z.number().int().min(0), isActive: z.boolean(), createdAt: z.date(), tags: z.array(z.string())});
// Automatically generates GraphQL type:// type User {// id: String!// name: String!// email: String!// age: Int!// isActive: Boolean!// createdAt: DateTime!// tags: [String!]!// }Custom Scalar Types
Section titled “Custom Scalar Types”import { GraphQLScalarType } from 'graphql';
const DateScalar = new GraphQLScalarType({ name: 'Date', description: 'Date custom scalar type', serialize: (value: Date) => value.toISOString(), parseValue: (value: string) => new Date(value), parseLiteral: (ast) => { if (ast.kind === Kind.STRING) { return new Date(ast.value); } return null; }});
// Custom scalars: follow the GraphQL schema builder in the veloce-ts repo for your version.// The snippet above shows a typical scalar shape; wiring depends on the current GraphQLPlugin implementation.Advanced Features
Section titled “Advanced Features”Authentication Integration
Section titled “Authentication Integration”import { Auth, CurrentUser } from 'veloce-ts';
@Resolver()export class UserResolver {
@GQLQuery() @Auth() // Require authentication async me(@CurrentUser() user: any): Promise<User> { return user; }
@GQLMutation() @Auth() async updateProfile( @CurrentUser() currentUser: any, @Arg('input', UpdateProfileSchema) input: any ): Promise<User> { // Only allow users to update their own profile if (currentUser.id !== input.userId) { throw new ForbiddenException('Cannot update other users'); }
return await userService.update(currentUser.id, input); }}Error Handling
Section titled “Error Handling”import { NotFoundException, ValidationException } from 'veloce-ts';
@Resolver()export class UserResolver {
@GQLQuery() async user(@Arg('id', z.string()) id: string): Promise<User> { const user = await userService.findById(id);
if (!user) { throw new NotFoundException(`User with id ${id} not found`); }
return user; }
@GQLMutation() async createUser(@Arg('input', CreateUserSchema) input: any): Promise<User> { try { return await userService.create(input); } catch (error) { if (error.code === 'DUPLICATE_EMAIL') { throw new ValidationException('Email already exists'); } throw error; } }}DataLoader Integration
Section titled “DataLoader Integration”import DataLoader from 'dataloader';
@Resolver()export class UserResolver { private userLoader = new DataLoader(async (ids: string[]) => { const users = await userService.findByIds(ids); return ids.map(id => users.find(user => user.id === id) || null); });
@GQLQuery() async users(): Promise<User[]> { const userIds = await userService.getAllUserIds(); return await this.userLoader.loadMany(userIds); }}Examples
Section titled “Examples”Complete User Management API
Section titled “Complete User Management API”import { Resolver, GQLQuery, GQLMutation, Arg } from 'veloce-ts/graphql';import { z } from 'zod';
const UserSchema = z.object({ id: z.string(), name: z.string(), email: z.string().email(), role: z.enum(['admin', 'user', 'guest']), createdAt: z.date()});
const CreateUserInput = z.object({ name: z.string().min(1), email: z.string().email(), role: z.enum(['admin', 'user', 'guest']).default('user')});
const UpdateUserInput = CreateUserInput.partial();
@Resolver()export class UserResolver {
@GQLQuery() async users(): Promise<User[]> { return await userService.findAll(); }
@GQLQuery() async user(@Arg('id', z.string()) id: string): Promise<User | null> { return await userService.findById(id); }
@GQLMutation() async createUser(@Arg('input', CreateUserInput) input: z.infer<typeof CreateUserInput>): Promise<User> { return await userService.create(input); }
@GQLMutation() async updateUser( @Arg('id', z.string()) id: string, @Arg('input', UpdateUserInput) input: z.infer<typeof UpdateUserInput> ): Promise<User> { return await userService.update(id, input); }
@GQLMutation() async deleteUser(@Arg('id', z.string()) id: string): Promise<boolean> { await userService.delete(id); return true; }}GraphQL Query Examples
Section titled “GraphQL Query Examples”# Query all usersquery GetUsers { users { id name email role createdAt }}
# Query specific userquery GetUser($id: String!) { user(id: $id) { id name email role }}
# Create user mutationmutation CreateUser($input: CreateUserInput!) { createUser(input: $input) { id name email role createdAt }}
# Update user mutationmutation UpdateUser($id: String!, $input: UpdateUserInput!) { updateUser(id: $id, input: $input) { id name email role }}
# Delete user mutationmutation DeleteUser($id: String!) { deleteUser(id: $id)}Variables Example
Section titled “Variables Example”{ "id": "user-123", "input": { "name": "John Doe", "email": "john@example.com", "role": "user" }}Best Practices
Section titled “Best Practices”1. Use Zod for Validation
Section titled “1. Use Zod for Validation”// Good: Strong typing with Zodconst CreateUserSchema = z.object({ name: z.string().min(1).max(100), email: z.string().email(), age: z.number().int().min(0).max(150)});
@GQLMutation()async createUser(@Arg('input', CreateUserSchema) input: z.infer<typeof CreateUserSchema>) { // input is fully typed and validated}2. Handle Errors Gracefully
Section titled “2. Handle Errors Gracefully”@GQLQuery()async user(@Arg('id', z.string()) id: string): Promise<User> { try { const user = await userService.findById(id); if (!user) { throw new NotFoundException('User not found'); } return user; } catch (error) { // Log error for debugging console.error('Error fetching user:', error); throw error; }}3. Use DataLoaders for Performance
Section titled “3. Use DataLoaders for Performance”// Avoid N+1 queries@Resolver()export class PostResolver { private userLoader = new DataLoader(async (userIds: string[]) => { return await userService.findByIds(userIds); });
@GQLQuery() async posts(): Promise<Post[]> { const posts = await postService.findAll();
// Load users efficiently const users = await this.userLoader.loadMany( posts.map(post => post.authorId) );
return posts.map(post => ({ ...post, author: users.find(user => user?.id === post.authorId) })); }}Next Steps
Section titled “Next Steps”- TaskMaster Example - See GraphQL in action
- Authentication Guide - Secure your GraphQL API
- WebSocket Guide - Real-time subscriptions
- Testing Guide - Test your GraphQL resolvers