Use graphql-gene
to generate automatically an executable schema out of your ORM models. Everything is fully typed and define once for both GraphQL and Typescript types. See Highlights section for more.
❤️ Provided by Accès Impôt's engineering team
![]() |
---|
🇨🇦 Online tax declaration service 🇨🇦 |
- ⚡️ Performant - Automatically avoid querying nested database relationships if they are not requested.
- 🔒 Secure - Easily create and share directives at the type or field level (i.e.
@userAuth
). - ⏰ Time-to-delivery - No time wasted writing similar resolvers.
- 🧩 Resolver template - Generates the resolver for you with deep
where
argument and more. -
Type safe - Resolver arguments and return value are deeply typed.
- 🎯 One source of truth - Types are defined once and shared between GraphQL and Typescript.
- 💥 Works with anything - New or existing projects. Works with any GraphQL servers, ORM, or external sources.
- 🔌 Plugins - Simple plugin system to potentially support any Node.js ORM. See Writing a Plugin.
Install graphql-gene
with the plugin you need for your ORM:
# pnpm
pnpm add graphql-gene @graphql-gene/plugin-sequelize
# yarn
yarn add graphql-gene @graphql-gene/plugin-sequelize
# npm
npm i graphql-gene @graphql-gene/plugin-sequelize
Create a file where you export all your GraphQL types including your database models, but also basic GraphQL types, inputs, enums.
import { defineEnum, defineType } from 'graphql-gene'
// All your ORM models
export * from './models'
// i.e. some basic GraphQL types
export const MessageOutput = defineType({
type: 'MessageTypeEnum!',
text: 'String!',
})
export const MessageTypeEnum = defineEnum(['info', 'success', 'warning', 'error'])
// i.e. assuming AuthenticatedUser is defined as alias in User.geneConfig
export { User as AuthenticatedUser, MutationLoginOutput } from '../models/User/User.model'
You can now create a declaration file to define the GeneContext
and GeneSchema
types used by graphql-gene
. You need to use the GeneTypesToTypescript
utility to type every GraphQL types in GeneSchema
.
You can also extend the context based on the GraphQL server you're using (optional).
import type { GeneTypesToTypescript } from 'graphql-gene'
import type { YogaInitialContext } from 'graphql-yoga'
import * as graphqlTypes from '../models/graphqlTypes'
declare module 'graphql-gene/schema' {
export interface GeneSchema extends GeneTypesToTypescript<typeof graphqlTypes> {
Query: object
Mutation: object
}
}
declare module 'graphql-gene/context' {
export interface GeneContext extends YogaInitialContext {}
}
The last step is to call generateSchema
and pass the returned schema
to your GraphQL server. You simply have to pass all types imported from graphqlTypes.ts as shown in the example below.
Please note that graphql-gene
is using Date
, DateTime
, or JSON
for certain data types. If you don't provide scalars for them, they will fallback to the String
type.
You can use the resolvers
option to provide the scalars as it accepts both resolvers and scalar objects (resolvers?: { [field: string]: GraphQLFieldResolver } | GraphQLScalarType
).
If you follow the example below, you will also need to install graphql-scalars
.
import { DateResolver, DateTimeResolver, JSONResolver } from 'graphql-scalars'
import { generateSchema } from 'graphql-gene'
import { pluginSequelize } from '@graphql-gene/plugin-sequelize'
import * as graphqlTypes from '../models/graphqlTypes'
const {
typeDefs,
resolvers,
schema,
schemaString,
schemaHtml,
} = generateSchema({
resolvers: {
Date: DateResolver,
DateTime: DateTimeResolver,
JSON: JSONResolver,
},
plugins: [pluginSequelize()],
types: graphqlTypes,
})
export { typeDefs, resolvers, schema, schemaString, schemaHtml }
The schema
returned is an executable schema so you can simply pass it to your GraphQL server:
import { createServer } from 'node:http'
import { createYoga } from 'graphql-yoga'
import { schema } from './schema'
const yoga = createYoga({ schema })
const server = createServer(yoga)
server.listen(4000, () => {
console.info('Server is running on http://localhost:4000/graphql')
})
You can also pass typeDefs
and resolvers
to a function provided by your GraphQL server to create the schema:
import { createServer } from 'node:http'
import { createSchema, createYoga } from 'graphql-yoga'
import { typeDefs, resolvers } from './schema'
const schema = createSchema({ typeDefs, resolvers })
const yoga = createYoga({ schema })
const server = createServer(yoga)
server.listen(4000, () => {
console.info('Server is running on http://localhost:4000/graphql')
})
You can look at the schema in graphql language using schemaString
and schemaHtml
returned by generateSchema
.
-
schemaString
: you can generate a file like schema.gql that you add to.gitignore
then use it to inspect the schema in your code editor. -
schemaHtml
: you can add a HTML endpoint like/schema
and respond withschemaHtml
. The HTML comes with syntax highlighting provided by unpkg.com and highlight.js.
Here's an example using Fastify:
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import fastify from 'fastify'
import { schema, schemaString, schemaHtml } from './schema'
//
// Your GraphQL server code
//
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const app = fastify({ logger: true })
if (process.env.NODE_ENV !== 'production') {
// Expose schema as HTML page with graphql syntax highlighting
app.get('/schema', (_, reply) => reply.type('text/html').send(schemaHtml))
// Generate a .gql file locally (no need to await)
fs.promises.writeFile(
path.resolve(__dirname, '../../schema.gql'),
schemaString
)
}

No need to write Query resolvers anymore! You can simply use the filter arguments that are automatically defined when using the default resolver at the Query level. The same filter arguments are also defined on all association fields so you can also query with nested filters.
import { Model } from 'sequelize'
import { extendTypes } from 'graphql-gene'
export class Product extends Model {
// ...
}
extendTypes({
Query: {
products: {
resolver: 'default',
returnType: '[Product!]',
},
},
})
query productsByColor($color: String) {
products(where: { color: { eq: $color } }, order: [name_ASC]) {
id
name
color
# Association fields also have filters
variants(where: { size: { in: ["US 10", "US 11"] } }) {
id
size
}
}
}
Argument | Description |
---|---|
id |
String - Entry id (only available for fields returning a single entry). |
page |
Int - Page number for query pagination. Default: 1 . |
perPage |
Int - Amount of results per page. Default: 10 . |
where |
Record<Attribute, Record<Operator, T>> - Where options generated based on the fields of the return type (i.e. where: { name: { eq: "Foo" } } ). |
where |
Record<Attribute, Record<Operator, T>> - Where options generated based on the fields of the return type (i.e. where: { name: { eq: "Foo" } } ). |
order |
[foo_ASC] - Array of enum values representing the order in which the results should be sorted. The enum values are defined based on the attribute name + _ASC or _DESC (i.e. order: [name_ASC, foo_DESC] ). |
Operator | Description |
---|---|
eq |
T - The value equals to... |
ne |
T - The value does not equals to... |
in |
[T] - The value is in... |
notIn |
[T] - The value is not in... |
null |
Boolean - The value is null if true . The value is not null if false . |
and |
[CurrentWhereOptionsInput!] - Array of object including the same operators. It represents a set of and conditions. |
or |
[CurrentWhereOptionsInput!] - Array of object including the same operators. It represents a set of or conditions. |
Operator | Description |
---|---|
lt |
String - The value is like... (i.e. { like: "%foo%" } ) |
lte |
String - The value is not like... (i.e. { notLike: "%foo%" } ) |
Operator | Description |
---|---|
lt |
T - The value is less than... |
lte |
T - The value is less than or equal to... |
gt |
T - The value is greater than... |
gte |
T - The value is greater than or equal to... |
By default, if a model is part of the types
provided to generateSchema
, it will be added to your schema.
Nevertheless, you might need to exclude some fields like password
, define queries or mutations. You can set GraphQL-specific configuration by adding a static readonly geneConfig
object to your model (more examples below) or use extendTypes
to add fields to Query/Mutation.
import { Model } from 'sequelize'
import { defineGraphqlGeneConfig, extendTypes } from 'graphql-gene'
export class User extends Model {
// ...
static readonly geneConfig = defineGraphqlGeneConfig(User, {
// Your config
})
}
extendTypes({
Query: {
foo: {
// ...
},
},
})
Name | Description |
---|---|
include ❔ |
(InferFields<M> | RegExp)[] - Array of fields to include in the GraphQL type. Default: all included. |
exclude ❔ |
(InferFields<M> | RegExp)[] - Array of fields to exclude in the GraphQL type. Default: ['createdAt', updatedAt'] . |
includeTimestamps ❔ |
boolean | ('createdAt' | 'updatedAt')[] - Include the timestamp attributes or not. Default: false . |
varType ❔ |
GraphQLVarType - The GraphQL variable type to use. Default: 'type' . |
directives ❔ |
GeneDirectiveConfig[] - Directives to apply at the type level (also possible at the field level). |
aliases ❔ |
Record<GraphqlTypeName], GeneConfig> - The values of "aliases" would be nested GeneConfig properties that overwrites the ones set at a higher level. This is useful for instances with a specific scope include more fields that the parent model (i.e. AuthenticatedUser being an alias of User ). Note that the alias needs to be exported from graphqlTypes.ts as well (i.e. export { User as AuthenticatedUser } from '../models/User/User.model' ). |
import type { InferAttributes, InferCreationAttributes } from 'sequelize'
import { Model, Table, Column, Unique, AllowNull, DataType } from 'sequelize-typescript'
import { defineEnum, defineType, extendTypes } from 'graphql-gene'
import { isEmail } from '../someUtils.ts'
export
@Table
class Prospect extends Model<InferAttributes<Prospect>, InferCreationAttributes<Prospect>> {
declare id: CreationOptional<number>
@Unique
@AllowNull(false)
@Column(DataType.STRING)
declare email: string
@Column(DataType.STRING)
declare language: string | null
}
extendTypes({
Mutation: {
registerProspect: {
args: { email: 'String!', locale: 'String' },
returnType: 'MessageOutput!',
resolver: async ({ args }) => {
// `args` type is inferred from the GraphQL definition above
// { email: string; locale: string | null | undefined }
const { email, locale } = args
if (!isEmail(email)) {
// The return type is deeply inferred from the `MessageOutput`
// definition. For instance, the `type` value must be:
// 'info' | 'success' | 'warning' | 'error'
return { type: 'error' as const, text: 'Invalid email' }
}
// No need to await
Prospect.create({ email, language: locale })
return { type: 'success' as const }
},
},
},
})
export const MessageOutput = defineType({
type: 'MessageTypeEnum!',
text: 'String',
})
export const MessageTypeEnum = defineEnum(['info', 'success', 'warning', 'error'])
geneConfig.directives
accepts an array of GeneDirectiveConfig
which will add the directive at the type level (current model). It is recommended to create directives as factory function using defineDirective
for better typing (see example below).
Directives are simply wrappers around resolvers following a middleware pattern. At the type level, it looks across your whole schema and wrap the resolver of fields returning the given type. This way, the field itself returns null
on error instead of returning an object with all its fields being null
.
type GeneDirectiveConfig<
TDirectiveArgs =
| Record<string, string | number | boolean | string[] | number[] | boolean[] | null>
| undefined,
TSource = Record<string, unknown> | undefined,
TContext = GeneContext,
TArgs = Record<string, unknown> | undefined,
> = {
name: string
args?: TDirectiveArgs
handler: GeneDirectiveHandler<TSource, TContext, TArgs>
}
type GeneDirectiveHandler<TSource, TContext, TArgs, TResult = unknown> = (options: {
source: Parameters<GraphQLFieldResolver<TSource, TContext, TArgs, TResult>>[0]
args: Parameters<GraphQLFieldResolver<TSource, TContext, TArgs, TResult>>[1]
context: Parameters<GraphQLFieldResolver<TSource, TContext, TArgs, TResult>>[2]
info: Parameters<GraphQLFieldResolver<TSource, TContext, TArgs, TResult>>[3]
resolve: () => Promise<TResult> | TResult
}) => Promise<void> | void
import { GraphQLError } from 'graphql'
import { defineDirective } from 'graphql-gene'
import { getQueryIncludeOf } from '@graphql-gene/plugin-sequelize'
import { User } from './User.model'
import { getJwtTokenPayload } from './someUtils'
export enum ADMIN_ROLES {
developer = 'developer',
manager = 'manager',
superAdmin = 'superAdmin',
}
declare module 'graphql-gene/context' {
export interface GeneContext {
authenticatedUser?: User | null
}
}
function throwUnauthorized(): never {
throw new GraphQLError('Unauthorized')
}
/**
* Factory function returning the directive object
*/
export const userAuthDirective = defineDirective<{
// Convert ADMIN_ROLES enum to a union type
roles: `${ADMIN_ROLES}`[]
}>(args => ({
name: 'userAuth', // only used to add `@userAuth` to the schema in graphql language
args,
async handler({ context, info }) {
// If it was previously set to `null`
if (context.authenticatedUser === null) return throwUnauthorized()
const isAuthorized = (user: User | null) =>
!args.roles.length || args.roles.some(role => user?.adminRole === role)
if (context.authenticatedUser) {
if (!isAuthorized(context.authenticatedUser)) throwUnauthorized()
return // Proceed if user is fetched and authorized
}
// i.e. `context.request` coming from Fastify
const authHeader = context.request.headers.get('authorization')
const [, token] =
authHeader?.match(/^Bearer\s+(\S+)$/) || ([] as (string | undefined)[])
if (!token) return throwUnauthorized()
// For performance: avoid querying nested associations if they are not requested.
// `getQueryIncludeOf` look deeply inside the operation (query or mutation) for
// the `AuthenticatedUser` type in order to know which associations are requested.
const includeOptions = getQueryIncludeOf(info, 'AuthenticatedUser', {
// Set to true if the directive is added to a field that is not of type "AuthenticatedUser"
lookFromOperationRoot: true,
})
const { id, email } = getJwtTokenPayload(token) || {}
if (!id && !email) return throwUnauthorized()
const user = await User.findOne({ where: { id, email }, ...includeOptions })
context.authenticatedUser = user
if (!user || !isAuthorized(user)) throwUnauthorized()
},
}))
The args
option allow you to use it in different contexts:
import { defineGraphqlGeneConfig, extendTypes } from 'graphql-gene'
import { userAuthDirective } from '.userAuthDirective.ts'
export
@Table
class User extends Model<InferAttributes<User>, InferCreationAttributes<User>> {
declare id: CreationOptional<number>
// ...
static readonly geneConfig = defineGraphqlGeneConfig(User, {
include: ['id', 'username'],
aliases: {
// Use an alias since `AuthenticatedUser` as a quite
// different scope than a public `User`.
AuthenticatedUser: {
include: ['id', 'email', 'username', 'role', 'address', 'orders'],
// `roles: []` means no specific admin `role` needed
// The user just needs to be authenticated.
directives: [userAuthDirective({ roles: [] })],
},
},
})
}
extendTypes({
Query: {
me: {
returnType: 'AuthenticatedUser',
// `context.authenticatedUser` is defined in `userAuthDirective`
resolver: ({ context }) => context.authenticatedUser,
},
},
})
The alias needs to be exported as well from graphqlTypes.ts:
export * from './models'
// Export the alias for typing
export { User as AuthenticatedUser } from '../models/User/User.model'
Another example for superAdmin
role:
static readonly geneConfig = defineGraphqlGeneConfig(AdminAccount, {
// i.e. Only allow super admin users to access the `AdminAccount` data
directives: [userAuthDirective({ roles: ['superAdmin'] })],
})
This is how the response would look like for Query.me
if the token is missing or invalid:
type Query {
me: AuthenticatedUser
}

Local development
# Install dependencies
pnpm install
#
# or if you're having issues on Apple M Chips:
# arch -arm64 pnpm install -f
# Develop
pnpm playground: dev
# Run ESLint
pnpm lint
# Run Vitest
pnpm test
# Run Vitest in watch mode
pnpm test:watch