This document specifies the design and behavior of the ORM-style GraphQL SDK generator.
- Overview
- Architecture
- Type System Design
- Select Type Safety
- Generated Code Structure
- API Reference
- Testing Requirements
The GraphQL Codegen ORM generates a Prisma-like TypeScript SDK from a GraphQL schema. It provides:
- Type-safe queries and mutations with full IntelliSense support
- Select-based field selection that infers return types from the select object
- Fluent API with
.execute(),.unwrap(), and.unwrapOr()methods - Query inspection via
.toGraphQL()for debugging
- Type Safety: Compile-time validation of all operations
- Developer Experience: Autocomplete, type inference, clear error messages
- Zero Runtime Overhead: All type checking happens at compile time
- GraphQL Fidelity: Generated queries match the schema exactly
GraphQL Schema (endpoint or .graphql file)
│
▼
┌─────────────────────────────────────┐
│ Schema Introspection │
│ - Parse types, fields, relations │
│ - Identify tables vs custom ops │
└─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ Code Generation │
│ - input-types.ts (types + filters) │
│ - select-types.ts (utilities) │
│ - models/*.ts (table models) │
│ - query/index.ts (custom queries) │
│ - mutation/index.ts (custom muts) │
│ - index.ts (createClient factory) │
└─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ Output Formatting (prettier) │
└─────────────────────────────────────┘
| Component | File | Purpose |
|---|---|---|
| Schema Source | src/cli/introspect/source.ts |
Fetch schema from endpoint or file |
| Table Inference | src/cli/introspect/infer-tables.ts |
Identify tables from schema |
| Input Types Generator | src/cli/codegen/orm/input-types-generator.ts |
Generate TypeScript types |
| Model Generator | src/cli/codegen/orm/model-generator.ts |
Generate table model classes |
| Custom Ops Generator | src/cli/codegen/orm/custom-ops-generator.ts |
Generate query/mutation operations |
| Client Generator | src/cli/codegen/orm/client-generator.ts |
Generate runtime utilities |
For each GraphQL type (table), we generate:
// The entity interface (matches GraphQL type)
export interface User {
id: string;
username: string | null;
displayName: string | null;
createdAt: string | null;
// ... all fields
}
// Entity with relations (includes related entities)
export interface UserWithRelations extends User {
posts?: PostConnection;
profile?: Profile | null;
// ... all relations
}For each entity, we generate a select type that defines what fields can be selected:
export type UserSelect = {
id?: boolean;
username?: boolean;
displayName?: boolean;
createdAt?: boolean;
// Relation fields allow nested select
posts?: boolean | {
select?: PostSelect;
first?: number;
// ... pagination args
};
profile?: boolean | {
select?: ProfileSelect;
};
};PostGraphile filter types for where clauses:
export type UserFilter = {
id?: UUIDFilter;
username?: StringFilter;
createdAt?: DatetimeFilter;
// Logical operators
and?: UserFilter[];
or?: UserFilter[];
not?: UserFilter;
};Custom mutations return payload types:
export interface SignInPayload {
clientMutationId?: string | null;
apiToken?: ApiToken | null;
}
export type SignInPayloadSelect = {
clientMutationId?: boolean;
apiToken?: boolean | {
select?: ApiTokenSelect;
};
};The select system MUST enforce these invariants:
- Only valid fields: Selecting a field that doesn't exist in the schema MUST produce a TypeScript error
- Nested validation: Invalid fields in nested selects MUST also produce errors
- Mixed field handling: Invalid fields MUST be caught even when mixed with valid fields
- Permissive for valid cases: Empty selects, boolean shorthand, and omitting select entirely MUST work
TypeScript has a quirk where excess property checking behaves differently depending on context:
type UserSelect = { id?: boolean; name?: boolean; };
// ERROR: TypeScript catches this (only invalid field)
const a: UserSelect = { invalid: true };
// NO ERROR: TypeScript allows this (valid + invalid mixed)
function fn<T extends UserSelect>(s: T) {}
fn({ id: true, invalid: true }); // Compiles!This is because:
- Direct assignment uses "freshness" checking
- Generic type parameters use structural subtyping
- An object with extra optional properties is still a valid subtype
We use a recursive type that explicitly rejects excess keys:
/**
* Recursively validates select objects, rejecting unknown keys.
* Returns `never` if any excess keys are found at any nesting level.
*/
export type DeepExact<T, Shape> = T extends Shape
? Exclude<keyof T, keyof Shape> extends never
? {
[K in keyof T]: K extends keyof Shape
? T[K] extends { select: infer NS }
? Shape[K] extends { select?: infer ShapeNS }
? { select: DeepExact<NS, NonNullable<ShapeNS>> }
: T[K]
: T[K]
: never
}
: never // Has excess keys at this level
: never; // Doesn't extend Shape at allHow it works:
T extends Shape- Basic structural checkExclude<keyof T, keyof Shape> extends never- Check for excess keys- If excess keys exist, return
never(causes type error) - For nested
{ select: ... }objects, recursively apply validation
The DeepExact type is applied in function signatures:
// Table model methods
findMany<const S extends UserSelect>(
args?: FindManyArgs<DeepExact<S, UserSelect>, UserFilter, UsersOrderBy>
): QueryBuilder<...>
// Custom mutations
signIn<const S extends SignInPayloadSelect>(
args: SignInVariables,
options?: { select?: DeepExact<S, SignInPayloadSelect> }
): QueryBuilder<...>// MUST ERROR: Invalid nested field
db.mutation.signIn(
{ input: { email: 'e', password: 'p' } },
{ select: { apiToken: { select: { refreshToken: true } } } }
// ~~~~~~~~~~~~ Error!
);
// MUST ERROR: Invalid field mixed with valid
db.user.findMany({
select: { id: true, invalid: true }
// ~~~~~~~ Error!
});
// MUST WORK: Valid fields only
db.user.findMany({
select: { id: true, username: true }
});
// MUST WORK: Empty select (returns all fields)
db.user.findMany({ select: {} });
// MUST WORK: No select parameter
db.user.findMany({ where: { id: { equalTo: '123' } } });
// MUST WORK: Boolean shorthand for relations
db.mutation.signIn(
{ input: { email: 'e', password: 'p' } },
{ select: { apiToken: true } }
);generated-orm/
├── index.ts # createClient factory, re-exports
├── client.ts # OrmClient class, QueryResult types
├── query-builder.ts # QueryBuilder class, document builders
├── select-types.ts # Type utilities (DeepExact, InferSelectResult, etc.)
├── input-types.ts # All TypeScript types (entities, filters, inputs)
├── types.ts # Scalar type mappings
├── models/
│ ├── user.ts # UserModel class
│ ├── post.ts # PostModel class
│ └── ... # One file per table
├── query/
│ └── index.ts # Custom query operations
└── mutation/
└── index.ts # Custom mutation operations
export function createClient(config: OrmClientConfig) {
const client = new OrmClient(config);
return {
// Table models
user: new UserModel(client),
post: new PostModel(client),
// ...
// Custom operations
query: createQueryOperations(client),
mutation: createMutationOperations(client),
};
}export class UserModel {
constructor(private client: OrmClient) {}
findMany<const S extends UserSelect>(
args?: FindManyArgs<DeepExact<S, UserSelect>, UserFilter, UsersOrderBy>
): QueryBuilder<{ users: ConnectionResult<InferSelectResult<UserWithRelations, S>> }> {
// Build GraphQL document and return QueryBuilder
}
findFirst<const S extends UserSelect>(
args?: FindFirstArgs<DeepExact<S, UserSelect>, UserFilter, UsersOrderBy>
): QueryBuilder<{ user: InferSelectResult<UserWithRelations, S> | null }> {
// ...
}
create<const S extends UserSelect>(
args: CreateArgs<DeepExact<S, UserSelect>, CreateUserInput['user']>
): QueryBuilder<{ createUser: { user: InferSelectResult<UserWithRelations, S> } }> {
// ...
}
update<const S extends UserSelect>(
args: UpdateArgs<DeepExact<S, UserSelect>, UserFilter, UserPatch>
): QueryBuilder<{ updateUser: { user: InferSelectResult<UserWithRelations, S> } }> {
// ...
}
delete(args: DeleteArgs<UserFilter>): QueryBuilder<{ deleteUser: { deletedUserId: string } }> {
// ...
}
}export class QueryBuilder<TResult> {
constructor(private config: QueryBuilderConfig) {}
// Get the generated GraphQL query string
toGraphQL(): string { ... }
// Execute and return discriminated union
async execute(): Promise<QueryResult<TResult>> { ... }
// Execute and throw on error
async unwrap(): Promise<TResult> { ... }
// Execute with fallback on error
async unwrapOr<D>(defaultValue: D): Promise<TResult | D> { ... }
}Maps the select object to the result type:
export type InferSelectResult<TEntity, TSelect> = TSelect extends undefined
? TEntity
: {
[K in keyof TSelect as TSelect[K] extends false | undefined ? never : K]:
TSelect[K] extends true
? K extends keyof TEntity
? TEntity[K]
: never
: TSelect[K] extends { select: infer NestedSelect }
? K extends keyof TEntity
? InferSelectResult<NonNullable<TEntity[K]>, NestedSelect>
: never
: K extends keyof TEntity
? TEntity[K]
: never;
};interface OrmClientConfig {
endpoint: string;
headers?: Record<string, string>;
}type QueryResult<T> =
| { ok: true; data: T; errors: undefined }
| { ok: false; data: null; errors: GraphQLError[] };interface FindManyArgs<TSelect, TWhere, TOrderBy> {
select?: TSelect;
where?: TWhere;
orderBy?: TOrderBy[];
first?: number;
last?: number;
after?: string;
before?: string;
offset?: number;
}interface ConnectionResult<T> {
nodes: T[];
totalCount: number;
pageInfo: PageInfo;
}
interface PageInfo {
hasNextPage: boolean;
hasPreviousPage: boolean;
startCursor?: string | null;
endCursor?: string | null;
}The following scenarios MUST produce TypeScript compile errors:
- Invalid field in select (top-level)
- Invalid field in nested select
- Invalid field mixed with valid fields
- Invalid field in relation select
- Typo in field name
The following scenarios MUST compile successfully:
- All valid fields
- Subset of valid fields
- Empty select object
- No select parameter
- Boolean shorthand for relations
- Nested select with valid fields
- Deep nesting (3+ levels)
- Execute returns correct data structure
- Error handling works (execute, unwrap, unwrapOr)
- Generated GraphQL matches expected format
- Variables are correctly passed
- Pagination parameters work
- Filters work correctly
- Full flow: createClient -> query -> execute
- Authentication flow with token refresh
- Complex nested queries
- Mutation with optimistic updates
The const modifier on type parameters (TypeScript 5.0+) enables:
- Literal type inference:
{ id: true }is inferred as{ id: true }not{ id: boolean } - Precise select tracking: We know exactly which fields were selected
- Accurate return types: The result type only includes selected fields
TypeScript uses structural typing, meaning a type is compatible if it has at least the required properties. For optional properties, an object with extra properties is still a valid subtype:
type A = { x?: number };
type B = { x?: number; y: string };
// B extends A is true, because B has all of A's propertiesThis is why we need DeepExact to explicitly check for and reject excess keys.
When DeepExact rejects a select object, TypeScript produces errors like:
Type 'true' is not assignable to type 'never'Object literal may only specify known properties
These are not the most intuitive, but they do indicate the location of the invalid field.