Migrating from TypeGraphQL
Why GQLoom
TypeGraphQL uses classes, decorators, and reflect-metadata. GQLoom weaves runtime schemas (such as Zod, Valibot, or Yup) and ORM models (such as Prisma, Drizzle, or MikroORM) into GraphQL schemas.
- One runtime schema or ORM model as the single source of truth instead of maintaining separate GraphQL classes, TypeScript types, and
class-validatordecorators. TypeScript types are inferred from the schema, GraphQL types are woven from it, and runtime input validation is executed by the schema itself. - Less boilerplate: you do not write
@ObjectTypeclasses, field decorators, and a parallel TypeScript type layer, as validation is built into the schema. - You drop
reflect-metadata,experimentalDecorators,emitDecoratorMetadata, global metadata registries, and built-in IoC containers (such as TypeDI). After full migration, you can uninstalltype-graphql,reflect-metadata, andclass-validator, and turn off decorator compiler flags intsconfig.json. - Direct reuse of Prisma, Drizzle, and MikroORM models as silks, with resolver factories generating standard CRUD operations without manual DTO classes.
field().load()batches relational N+1 queries without custom DataLoader classes.- Middleware and
useContext()replace@Authorizeddecorators and constructor injection. Subscriptions and Apollo Federation work without decorators or code generation.
Overview and mental model
| TypeGraphQL | GQLoom | |
|---|---|---|
| Types | @ObjectType classes | Zod / Valibot / ORM models (silks) |
| Operations | @Resolver classes | resolver({ ... }) objects |
| Schema | buildSchema({ resolvers }) scans global metadata | weave(ZodWeaver, ...resolvers) takes them explicitly |
| Validation | Optional class-validator | The schema itself; inputs are validated by default |
| Auth / DI | @Authorized, container | Middleware, useContext() |
Migration recommendations:
- Use Zod (
@gqloom/zod) by default. If your project already uses Valibot, or the data layer is backed by Prisma, Drizzle, or MikroORM, weave those existing schemas or models directly instead of defining duplicate DTOs. - Migrate incrementally by module. Keep
type-graphqlinstalled during migration. BothGraphQLSchemainstances can coexist and be combined withmergeSchemas, or mounted on separate HTTP endpoints. - Queries and mutations remain separate definitions in GQLoom. Do not combine them into a single handler.
Concept map
The table below highlights concepts and APIs with notable differences:
| TypeGraphQL | GQLoom | Notes |
|---|---|---|
@ObjectType / @Field | z.object(), names via __typename or z.meta({ title }), descriptions via z.describe() or z.meta({ description }) | Put computed fields on resolver.of, not on the silk |
@InputType / @ArgsType | A separate input silk | Do not reuse the object silk |
@Resolver + @Query / @Mutation | resolver + query / mutation | |
@FieldResolver + @Root | resolver.of(Type, { field }) | Parent is the first argument of resolve |
@Arg / @Args | .input({ ... }) | Arguments become one object |
@Ctx | useContext() | Requires asyncContextProvider |
@Info | useResolverPayload().info | |
@Authorized + authChecker | Middleware | Operations use .use(), fields use field().use() |
@UseMiddleware | .use() or weave(..., middleware) | Koa-style onion model |
container / constructor injection | Context or a module-level provider | No built-in IoC container |
class-validator | Zod rules | See validation gotchas below |
emitSchemaFile | printSchema(lexicographicSortSchema(schema)) | See Printing Schema |
registerEnumType | z.enum + asEnumType | |
createUnionType | z.union + asUnionType / resolveType | |
@InterfaceType | asObjectType({ interfaces }) | See interfaces gotcha below for polymorphic queries |
@Directive / @Extensions | extensions (Federation: Federation) | |
complexity | extensions.complexity | |
DataLoader | field().load() | |
orphanedTypes | Pass unused silks into weave |
Common field options mapping:
descriptionon silk fields →z.describe()orz.meta({ description })(operations use.description())deprecationReason→.deprecationReason()nullable: true→.nullish()(see Gotchas)defaultValue→ Zod.default(...)
asObjectType and asField are the last line of defense between a Zod schema and the GraphQL schema. Declare ordinary names and descriptions with z.describe() or z.meta(). Reach for asObjectType and asField only when Zod metadata cannot express GraphQL-only configurations, such as interfaces (asObjectType({ interfaces })), hiding a field or overriding its GraphQL type (asField({ type })), complexity, or extensions.
Migration practices
Scaffold
Install graphql, @gqloom/core, zod, @gqloom/zod, and your HTTP server adapter. New GQLoom files do not require experimentalDecorators.
import { weave } from "@gqloom/core"
import { asyncContextProvider } from "@gqloom/core/context"
import { ZodWeaver } from "@gqloom/zod"
import { resolver, query } from "@gqloom/core"
import * as z from "zod"
const helloResolver = resolver({
hello: query(z.string()).resolve(() => "Hello"),
})
export const schema = weave(
ZodWeaver,
asyncContextProvider,
helloResolver
)To match TypeGraphQL's DateTimeISO scalar, map z.date() using ZodWeaver.config and GraphQLDateTimeISO (where the scalar name in SDL is DateTimeISO):
import { ZodWeaver } from "@gqloom/zod"
import { GraphQLDateTimeISO } from "graphql-scalars"
import * as z from "zod"
export const zodWeaverConfig = ZodWeaver.config({
presetGraphQLType: (schema) => {
if (schema instanceof z.ZodDate) return GraphQLDateTimeISO
},
})Pass zodWeaverConfig to weave. For HTTP server setup, see Adapters.
ZodWeaver.config with GraphQLDateTimeISO maps all z.date() instances to the SDL scalar name DateTimeISO (ISO string). TypeGraphQL's graphql-scalars example uses Timestamp (unix milliseconds). These are different scalars; pick the one that matches your existing SDL rather than copying the DateTimeISO configuration blindly. presetGraphQLType for DateTimeISO on z.date() still follows Zod nullability (z.date() maps to DateTimeISO!, .nullish() maps to nullable DateTimeISO).
Per-field custom scalars (NonEmptyString, NonNegativeInt, Timestamp, etc.) go through asField({ type }) as the last line of defense (see Zod). Unlike presetGraphQLType, asField({ type: GraphQLTimestamp }) replaces the GraphQL type directly and does not retain the non-null ! modifier: z.date().register(asField, { type: GraphQLTimestamp }) weaves a nullable Timestamp. To match non-null Timestamp!, wrap the scalar in new GraphQLNonNull(GraphQLTimestamp) (imported from graphql). The same applies to GraphQLNonEmptyString.
import { asField } from "@gqloom/zod"
import { GraphQLNonEmptyString, GraphQLTimestamp } from "graphql-scalars"
import { GraphQLNonNull } from "graphql"
import * as z from "zod"
const Recipe = z.object({
title: z.string().register(asField, {
type: new GraphQLNonNull(GraphQLNonEmptyString),
}),
creationDate: z.date().register(asField, {
type: new GraphQLNonNull(GraphQLTimestamp),
}),
})Types and resolvers
Name object types using __typename or z.meta({ title }). Define input types as separate silks named with z.meta({ title }). Reserve asObjectType as the last line of defense for GraphQL-only configurations. Queries, mutations, and computed fields are defined in resolver or resolver.of. See the reference implementation for a complete example.
Auth and context
import type { Middleware } from "@gqloom/core"
import { GraphQLError } from "graphql"
import { useContext } from "@gqloom/core/context"
interface Context {
user?: { roles: string[] }
}
export function authGuard(...roles: string[]): Middleware {
return async (next) => {
const user = useContext<Context>().user
if (user == null) throw new GraphQLError("Not authenticated")
if (roles.length > 0 && !roles.some((role) => user.roles.includes(role))) {
throw new GraphQLError("Not authorized")
}
return next()
}
}Attach the middleware to an individual operation with .use(authGuard("ADMIN")), or pass it to weave as global middleware.
TypeGraphQL @Authorized() or @Authorized("ADMIN") on a class field is not the same as on a query or mutation. Keep the property on the object silk so the GraphQL SDL includes the field (such as ratings or ingredients). Place the authorization guard on field().use(authGuard()) or field().use(authGuard("ADMIN")) inside resolver.of. Fields defined only on silks do not execute middleware. Operations continue to use .use(authGuard()) as shown above.
import { field, resolver } from "@gqloom/core"
import * as z from "zod"
const Recipe = z.object({ ratings: z.array(z.int()) }).meta({ title: "Recipe" })
export const recipeResolver = resolver.of(Recipe, {
ratings: field(z.array(z.int()))
.use(authGuard("ADMIN"))
.resolve((recipe) => recipe.ratings),
})Services previously injected via constructor parameters should be moved to the request context or module-level providers. Do not instantiate services with new Service() inside resolve unless the service is completely stateless.
ORMs
If your project already uses Prisma, Drizzle, or MikroORM, pass the entity models directly as silks and generate standard CRUD operations using resolver factories instead of translating @Entity and @ObjectType into Zod schemas manually. See Prisma, Drizzle, and MikroORM.
For N+1 queries on relational fields, use field().load() without creating custom DataLoader classes.
Gotchas
Object and input silk separation: GraphQL requires output types and input types to remain distinct. TypeGraphQL uses separate
@ObjectTypeand@InputTypeclasses; in GQLoom, create separate silks rather than reusing an object silk as an input type.Nullability and
.optional(): In Zod,.optional()indicates that an object property may be omitted (undefined), but does not map to a nullable GraphQL field (null). To match TypeGraphQL'snullable: true(which produces a nullableString), use.nullish()or.nullable():
| TypeGraphQL | GraphQL | Zod |
|---|---|---|
@Field() title: string | String! | z.string() |
@Field({ nullable: true }) description?: string | String | z.string().nullish() |
- Input validation and error formats: TypeGraphQL allows disabling validation via
validate: false, whereas GQLoom has novalidate: falseand validates inputs against the schema before invoking theresolvefunction. Arguments that fail validation reject the request before execution. If your application previously depended on receiving unvalidated input, adjust the schema accordingly. The error format also changes:class-validatorthrowsArgumentValidationError(extensions.validationErrors), while GQLoom returns standard schema issues (extensions.issuesin Zod). Update any clients that parseextensions.validationErrors. See Zod for schema validation rules.
| class-validator | Zod |
|---|---|
@MaxLength(n) | z.string().max(n) |
@MinLength(n) | z.string().min(n) |
@Length(min, max) | z.string().min(min).max(max) |
@Min(n) / @Max(n) on int | z.int().min(n) / z.int().max(n) |
When migrating nullable fields with constraints such as @Field({ nullable: true }) and @Length(30, 255), use z.string().min(30).max(255).nullish() with .nullish() on the outside of the chain, rather than .optional().
useContext()requiresasyncContextProvider: CallinguseContext()requiresasyncContextProviderto be passed intoweave. On runtimes withoutAsyncLocalStoragesupport (such as certain Edge runtimes or browsers), access context directly viauseResolverPayload().context.Datetypes andDateTimeISO: By default,z.date()is woven as a GraphQLString. To match TypeGraphQL and output aDateTimeISOscalar, configure the scalar mapping explicitly usingZodWeaver.config.Argument default values in SDL: Using
.default(...)on an input schema applies the default value during runtime parsing. However, the generated GraphQL SDL may still show the argument as nullable rather thanInt! = 0.Interfaces and polymorphic queries: When implementor silks declare interfaces using
asObjectType({ interfaces: [IPerson] }), GQLoom weavesinterface IPersonandtype Student implements IPerson. However, querying an interface silk directly withquery(z.array(IPerson))causes GQLoom to weaveIPersonas an object type. If bothquery(z.array(IPerson))and implementors referencinginterfaces: [IPerson]exist in the same weave, GraphQL throwsSchema must contain uniquely named types but contains multiple types named "IPerson". To return polymorphic lists without type conflicts, use a discriminated union on the query instead:z.discriminatedUnion("__typename", [Student, Employee])(orz.unionwithasUnionType/resolveType), and do not callquery(z.array(IPerson))in the same weave as those implementors. The resulting SDL producesunion Persons = Employee | Student, and clients query fields using__typenameand inline fragments. Note that a plainz.string()for anidfield weaves asString!; to produce GraphQLID!, usez.string().uuid(),cuid(), orulid()(see Zod).
import { query, resolver } from "@gqloom/core"
import { asObjectType } from "@gqloom/zod"
import * as z from "zod"
const IPerson = z.object({
__typename: z.literal("IPerson").nullish(),
id: z.string(),
name: z.string(),
})
const Student = z
.object({
__typename: z.literal("Student"),
id: z.string(),
name: z.string(),
universityName: z.string(),
})
.register(asObjectType, { interfaces: [IPerson] })
const Employee = z
.object({
__typename: z.literal("Employee"),
id: z.string(),
name: z.string(),
companyName: z.string(),
})
.register(asObjectType, { interfaces: [IPerson] })
const Persons = z.discriminatedUnion("__typename", [Student, Employee])
export const personResolver = resolver({
persons: query(z.array(Persons)).resolve(() => []),
})When to stop
If you encounter the following patterns, evaluate your architecture before attempting a direct migration:
- NestJS integration: Projects using NestJS with TypeGraphQL or
@nestjs/graphqlcode-first rely heavily on NestJS decorators and dependency injection, requiring architectural redesign. - Request-scoped IoC: TypeGraphQL's
using-scoped-containerand per-requestcontainer.getpatterns have no container lifecycle equivalent in GQLoom. Use request-scoped context instead. - Generic resolvers, deep inheritance, and mixins: GQLoom uses functional composition. Flatten these patterns into standalone
resolverobjects or helper factory functions. - Custom parameter decorators (
createParameterDecorator): Replace these withuseContext()or.input()definitions.
TypeGraphQL's simpleResolvers setting can generally be ignored. For federation, subscriptions, and custom scalars, see Federation, Subscription, and custom Zod mappings.
Verification
The goal of migration is maintaining schema contracts and operation behavior:
- Export the existing GraphQL SDL from TypeGraphQL using
emitSchemaFileorprintSchema. - Export the GQLoom schema using the same method:
import { weave } from "@gqloom/core"
import { query, resolver } from "@gqloom/core"
import { ZodWeaver } from "@gqloom/zod"
import { lexicographicSortSchema, printSchema } from "graphql"
import * as z from "zod"
const helloResolver = resolver({
hello: query(z.string()).resolve(() => "Hello"),
})
const schema = weave(ZodWeaver, helloResolver)
export const sdl = printSchema(lexicographicSortSchema(schema))- Compare type names, fields, arguments, nullability, and enum values between both SDL files.
- Replay existing queries, mutations, and subscriptions to verify response
data. Validate authorization and input validation errors against the updated error formats (see Gotchas).
After all modules are migrated and verified, remove type-graphql, reflect-metadata, and class-validator from your dependencies, and disable experimentalDecorators and emitDecoratorMetadata in tsconfig.json.
Reference implementation
The following example ports the official TypeGraphQL simple-usage Recipe example to GQLoom. Computed fields (specification, averageRating, ratingsCount) are defined on resolver.of rather than the base object silk.
import {
field,
mutation,
query,
resolver,
weave,
} from "@gqloom/core"
import { ZodWeaver } from "@gqloom/zod"
import { GraphQLDateTimeISO } from "graphql-scalars"
import * as z from "zod"
const zodWeaverConfig = ZodWeaver.config({
presetGraphQLType: (schema) => {
if (schema instanceof z.ZodDate) return GraphQLDateTimeISO
},
})
const Recipe = z
.object({
title: z.string(),
description: z
.string()
.nullish()
.describe("The recipe description with preparation info"),
ratings: z.array(z.int()),
creationDate: z.date(),
})
.meta({
title: "Recipe",
description: "Object representing cooking recipe",
})
type IRecipe = z.infer<typeof Recipe>
const RecipeInput = z
.object({
title: z.string(),
description: z.string().nullish(),
})
.meta({ title: "RecipeInput" })
const items: IRecipe[] = [
{
title: "Recipe 1",
description: "Desc 1",
ratings: [0, 3, 1],
creationDate: new Date("2018-04-11"),
},
]
export const recipeResolver = resolver.of(Recipe, {
recipe: query(Recipe.nullish())
.input({ title: z.string() })
.resolve(({ title }) => items.find((recipe) => recipe.title === title)),
recipes: query(z.array(Recipe))
.description("Get all the recipes from around the world")
.resolve(() => items),
addRecipe: mutation(Recipe)
.input({ recipe: RecipeInput })
.resolve(({ recipe }) => {
const created: IRecipe = {
...recipe,
ratings: [],
creationDate: new Date(),
}
items.push(created)
return created
}),
specification: field(z.string().nullish())
.deprecationReason("Use 'description' field instead")
.resolve((recipe) => recipe.description),
averageRating: field(z.number().nullish()).resolve((recipe) => {
if (recipe.ratings.length === 0) return null
return recipe.ratings.reduce((a, b) => a + b, 0) / recipe.ratings.length
}),
ratingsCount: field(z.int())
.input({ minRate: z.int().default(0) })
.resolve((recipe, { minRate }) => {
return recipe.ratings.filter((rating) => rating >= minRate).length
}),
})
export const schema = weave(ZodWeaver, zodWeaverConfig, recipeResolver)@ObjectType({ description: "Object representing cooking recipe" })
class Recipe {
@Field()
title!: string
@Field({ nullable: true, description: "The recipe description with preparation info" })
description?: string
@Field((type) => [Int])
ratings!: number[]
@Field()
creationDate!: Date
@Field((type) => String, {
nullable: true,
deprecationReason: "Use 'description' field instead",
})
get specification(): string | undefined {
return this.description
}
@Field((type) => Float, { nullable: true })
get averageRating(): number | null { /* ... */ }
}
@Resolver((of) => Recipe)
class RecipeResolver {
@Query((returns) => Recipe, { nullable: true })
recipe(@Arg("title") title: string) { /* ... */ }
@Query((returns) => [Recipe], {
description: "Get all the recipes from around the world",
})
recipes() { /* ... */ }
@Mutation((returns) => Recipe)
addRecipe(@Arg("recipe") recipeInput: RecipeInput) { /* ... */ }
@FieldResolver()
ratingsCount(
@Root() recipe: Recipe,
@Arg("minRate", (type) => Int, { defaultValue: 0 }) minRate: number,
) { /* ... */ }
}Compared with TypeGraphQL's official SDL, ratingsCount.minRate weaves as a nullable argument because .default(0) supplies the default at parse time rather than generating Int! = 0 in SDL. The field set, main type nullability, and the DateTimeISO scalar match the original schema.
Related documentation:
- Zod:
z.describe(),z.meta(), enums, unions, interfaces, andasObjectType/asFieldas the last line of defense - Resolver:
resolver.of,query,mutation, andfield - Middleware: Auth, logging, and output validation
- Context:
useContextandasyncContextProvider - DataLoader:
field().load()