Skip to content

Conversation

@quinnjr
Copy link

@quinnjr quinnjr commented Dec 12, 2025

ESM-Compatible Circular Import Resolution

Fixes unlight/prisma-nestjs-graphql#245

Problem

When using ES Modules (ESM) with the generated GraphQL types, Node.js throws ReferenceError: Cannot access 'X' before initialization errors due to circular dependencies between generated input types. This is particularly common with:

  • *WhereInput*ListRelationFilter relationships
  • Self-referential types (e.g., AND, OR, NOT fields)
  • Complex model relationships that create import cycles

CommonJS handles these circular dependencies differently due to its synchronous module loading, but ESM's hoisted imports cause the initialization order issues.

Solution

This PR introduces a lazy type registry pattern that breaks circular dependencies at runtime while maintaining full TypeScript type safety.

New Configuration Option

generator nestgraphql {
  provider      = "node node_modules/prisma-nestjs-graphql"
  output        = "../src/@generated"
  esmCompatible = true  // Enable ESM-compatible generation
}

How It Works

  1. Type-only imports for cross-referenced types (erased at compile time, no runtime dependency)
  2. Lazy type registry for runtime type resolution in decorators
  3. Central registration file ensures all modules are loaded before types are accessed

Generated Code Changes

Before (causes circular import errors in ESM):

import { BatchFileWhereInput } from './batch-file-where.input';

@InputType()
export class BatchFileListRelationFilter {
  @Field(() => BatchFileWhereInput, { nullable: true })
  every?: BatchFileWhereInput;
}

After (ESM compatible):

import type { BatchFileWhereInput } from './batch-file-where.input';
import { registerType, getType } from '../type-registry';

@InputType()
export class BatchFileListRelationFilter {
  @Field(() => getType('BatchFileWhereInput')(), { nullable: true })
  every?: BatchFileWhereInput;
}

registerType('BatchFileListRelationFilter', BatchFileListRelationFilter);

New Generated Files

When esmCompatible = true:

  1. type-registry.ts - Provides registerType() and getType() functions for lazy type resolution
  2. register-all-types.ts - Imports all generated files to ensure type registration

Usage

Add this import at the top of your application entry point (e.g., app.module.ts or main.ts):

// Must be first import to register all types before they're used
import './prisma/@generated/register-all-types';

Files Changed

New Files

  • src/handlers/type-registry.ts - Generates the type registry utility
  • src/handlers/register-all-types.ts - Generates the central registration file
  • src/helpers/detect-circular-deps.ts - Circular dependency detection utilities
  • src/test/esm-circular-deps.spec.ts - Unit tests for circular dependency detection

Modified Files

  • src/generate.ts - Integrated circular dependency detection and new generators
  • src/handlers/input-type.ts - Added ESM-compatible type generation for inputs
  • src/handlers/model-output-type.ts - Added ESM-compatible type generation for models
  • src/handlers/output-type.ts - Fixed Decimal import path
  • src/helpers/import-declaration-map.ts - Added addType() method for type-only imports
  • src/helpers/create-config.ts - Added esmCompatible configuration option
  • src/types.ts - Added circularDependencies to event arguments, updated DMMF imports for Prisma v7

Dependency Updates

  • Updated @prisma/generator-helper to ^7.0.0 for Prisma v7 compatibility
  • Updated @prisma/client peer dependency to support v7
  • Updated ts-morph range to 11 - 24
  • Added .npmrc with legacy-peer-deps=true for peer dependency resolution

Technical Details

Circular Dependency Detection

The generator builds a dependency graph from the Prisma schema and uses DFS to detect cycles. Only types involved in circular dependencies use the lazy loading pattern, minimizing the performance impact.

Type Registry

// type-registry.ts
const registry = new Map<string, unknown>();

export function registerType(name: string, type: unknown): void {
  registry.set(name, type);
}

export function getType(name: string): () => unknown {
  return () => registry.get(name);
}

Lazy Field Decorator

The getType() function returns a thunk that's evaluated lazily by NestJS when building the GraphQL schema:

@Field(() => getType('TypeName')(), { nullable: true })

The double function call getType('TypeName')() is intentional:

  1. getType('TypeName') returns a function () => registry.get('TypeName')
  2. The outer arrow function () => ... ensures NestJS evaluates it lazily
  3. The inner () call retrieves the actual type from the registry

Testing

  • Unit tests for circular dependency detection algorithm
  • Integration tests for ESM-compatible generation
  • Manual testing with complex Prisma schemas containing multiple circular relationships

Breaking Changes

None. The esmCompatible option is opt-in and defaults to false, maintaining backward compatibility with existing CommonJS setups.

Migration Guide

  1. Update your generator configuration:

    generator nestgraphql {
      // ... existing config
      esmCompatible = true
    }
  2. Regenerate your types:

    npx prisma generate
  3. Add the registration import to your app entry point:

    // app.module.ts or main.ts (must be first import)
    import './prisma/@generated/register-all-types';
  4. Remove any manually defined enums that duplicate generated ones to avoid "Schema must contain uniquely named types" errors.

Add support for resolving circular imports in ESM environments by:

- Detect circular dependencies between models using dependency graph analysis
- Generate type-registry.ts for lazy type resolution
- Use type-only imports for circular dependencies
- Add getType() lazy lookup in @field() decorators for circular refs
- Add registerType() calls after class definitions
- Add esmCompatible config option to enable/disable the feature
- Add prepare script to build dist on install from GitHub
- Update @apollo/server to ^5.0.0 for peer dependency compatibility

This fixes the 'Cannot access X before initialization' error that occurs
when generated ES6 modules have circular imports between models.
@unlight
Copy link
Owner

unlight commented Dec 13, 2025

- CI is failing
- Exclude dist files from version control
- I did found getType implementation
- Updating to next major versions of dependencies introduce next major release of library

Some thing I did not get:

@Field(() => BatchFileWhereInput, { nullable: true })

This is already lazy access.

@unlight
Copy link
Owner

unlight commented Dec 13, 2025

Issue #245 mentioned index.ts, means some reExport option is used

import { BatchFileWhereInput } from './batch-file-where.input';

@InputType()
export class BatchFileListRelationFilter {
  @Field(() => BatchFileWhereInput, { nullable: true })
  every?: BatchFileWhereInput;
}

This does not look like, no circular issues here

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

reExport creates unresolvable circular dependencies

2 participants