Code Generation

Disc generates a fully-typed client from your EdgeQL schema in TypeScript, Rust, or Go. Running disc codegen reads your .disc (or .gel / .esdl) schema files and produces interfaces, insert/update types, enum types, query builder classes, and a typed client -- giving you end-to-end type safety from schema to application code.

Three language targets. disc codegen emits TypeScript by default; disc codegen --rust emits a Cargo crate and disc codegen --go emits a Go package. All three come from the same language-neutral IR, so the type mappings, insert/update exclusion rules, and query-builder surface documented below apply to every target. The bulk of this page uses TypeScript for its examples; see Architecture & Language Targets for the Rust and Go specifics.

Codegen is one of two type-safety paths. For projects that prefer to skip the build step entirely, the SDK ships a runtime query builder (createQueryBuilder(client, schema)) where defineSchema() declares types in TypeScript and the same end-to-end inference applies — no generated files, no codegen step in CI. See Client SDK → Codegen-free query builder for the alternative pattern. Both paths use the same underlying client.query() runtime; pick whichever fits your build pipeline.

Related documentation: Schema | Client SDK | EdgeQL

Running Codegen

Basic Usage

disc codegen

Reads schema files from dbschema/ (looks for .disc files first, then .gel, then .esdl), generates TypeScript files, and writes them to ./dbschema/disc-client/ by default. Pass --output <dir> to write elsewhere.

Choosing a Language

disc codegen          # TypeScript -> ./dbschema/disc-client
disc codegen --rust   # Rust crate -> ./dbschema/disc-client-rust
disc codegen --go     # Go package -> ./dbschema/disc-client-go

Each flag replaces the TypeScript output rather than adding to it -- run the command once per language you need. If both --rust and --go are passed, --rust wins. --output overrides the default directory for any target.

Custom Output Directory

disc codegen --output ./src/generated/

Writes generated files to the specified directory. The directory is created if it does not exist.

Programmatic Usage

import { generateTypeScript, writeGeneratedFiles } from "disc/codegen/mod.ts";
import type { Schema } from "disc/compiler/context.ts";

const schema: Schema = /* parsed from SDL via SchemaManager */;

const result = generateTypeScript(schema, {
  includeClient: true,
  includeQueryBuilders: true,
  outputDir: "./dbschema/disc-client",
  target: "client"
});

if (result.errors.length > 0) {
  console.error("Codegen errors:", result.errors);
} else {
  await writeGeneratedFiles(result, ".");
  console.log(`Generated ${result.files.length} files`);
}

Generated File Structure

Codegen produces four files plus an embedded SDK directory:

dbschema/disc-client/
  client.ts    # Typed DiscClient extending the SDK base client
  index.ts     # Barrel file re-exporting everything
  queries.ts   # Query builder classes (one per object type)
  types.ts     # Type interfaces, enums, insert/update/filter types, utility types
  sdk/         # Materialized SDK copy; client.ts and queries.ts import compileFilter,
               # FilterArg, and TypeInfo from ./sdk/mod.ts

In multi-module schemas (schemas with more than one module), the types file is named interfaces.ts instead of types.ts and uses TypeScript namespaces to separate modules.

The sdk/ copy is written from the SDK embedded in your disc binary, tracked by a sdk/.disc-sdk-marker holding the binary version and a hash of the embedded SDK bytes. Re-running disc codegen rewrites sdk/ whenever either changes — so after upgrading or rebuilding disc, a plain disc codegen picks up the new SDK even if the version string is unchanged. If you ever suspect a stale copy, delete the sdk/ directory and regenerate.

All generated files include a header comment:

/**
 * Type Definitions
 * Generated by Disc TypeScript Codegen
 * Generated at: 2024-06-01T12:00:00.000Z
 *
 * DO NOT EDIT THIS FILE MANUALLY
 */

Type Interfaces

Each object type in your schema becomes a TypeScript interface. Each property and link is mapped to the appropriate TypeScript type.

Schema Example

module default {
  type User {
    active: bool;
    age: int32;
    bio: str;
    created_at: datetime {
      default := datetime_current();
      readonly := true;
    };
    required email: str {
      constraint exclusive;
      constraint max_len_value(255);
    };
    required name: str;
    multi posts: Post;
  };

  type Post {
    required author: User;
    required body: str;
    created_at: datetime {
      default := datetime_current();
    };
    required title: str;
  };
};

Generated Interface

/**
 * User type from EdgeQL schema
 * Table: user
 */
export interface User {
  /** bool */
  active?: boolean | null;
  /** int32 */
  age?: number | null;
  /** str */
  bio?: string | null;
  /**
   * datetime
   * @readonly
   * @default
   */
  created_at?: Date | null;
  /**
   * str (required)
   * @constraint exclusive
   * @constraint max_len_value(255)
   */
  email: string;
  /** Unique identifier */
  id: string;
  /** str (required) */
  name: string;
  /** Link to Post (many) */
  posts?: Post[];
}

Key rules:

Property Type Mappings

EdgeQL types are mapped to TypeScript types as follows:

EdgeQL Type TypeScript Type Nullable Type Array Type
bool boolean boolean | null boolean[]
bytes Uint8Array Uint8Array | null Uint8Array[]
cal::date_duration string string | null string[]
cal::local_date string string | null string[]
cal::local_datetime Date Date | null Date[]
cal::local_time string string | null string[]
cal::relative_duration string string | null string[]
datetime Date Date | null Date[]
decimal number number | null number[]
duration string string | null string[]
float32 number number | null number[]
float64 number number | null number[]
int16 number number | null number[]
int32 number number | null number[]
int64 bigint bigint | null bigint[]
json unknown unknown | null unknown[]
str string string | null string[]
uuid string string | null string[]

int64 maps to bigint (not number) so values beyond Number.MAX_SAFE_INTEGER survive without precision loss. You can pass bigint values straight back as query variables — new DiscClient().query("… <int64>$n", { n: 0n }) — and the client encodes them as numeric strings on the wire automatically. On the way back, int64 arrives as a numeric string; pass { revive: true } (or use parseInt64) to get a bigint.

SQL type names (text, integer, boolean, timestamptz, etc.) are also recognized for backward compatibility and mapped through to their EdgeQL equivalents.

Object types that do not match any built-in mapping are used as-is (e.g., a link to User produces the TypeScript type User).

Insert Types

Insert types are smart subsets of the full interface, designed for creating new objects. They exclude properties that should not or cannot be set during insertion.

Exclusion Rules

Excluded When Reason
Property is id Auto-generated UUID, never set manually.
Property is computed Virtual property evaluated at query time, not stored.
Property is readonly AND has a default Server sets the value automatically (e.g., created_at).

Properties with defaults are optional in the insert type even if they are required in the schema, because the server will fill in the default.

Example

Given the User type above:

export interface UserInsert {
  active?: boolean; // optional in schema
  age?: number;     // optional in schema
  bio?: string;     // optional in schema
  email: string;    // required, no default
  name: string;     // required, no default
}

Note that id is excluded (auto-generated), created_at is excluded (readonly + has default), and posts is a link (not a property).

Update Types

Update types exclude properties that cannot be modified after creation. All remaining properties are optional since you typically update only a subset of fields.

Exclusion Rules

Excluded When Reason
Property is id Primary key, never updated.
Property is computed Virtual property, not stored.
Property is readonly Cannot be changed after creation (e.g., created_at).

Example

export interface UserUpdate {
  active?: boolean;
  age?: number;
  bio?: string;
  email?: string;
  name?: string;
}

Note that created_at is excluded because it is readonly, regardless of whether it has a default.

Enum Types

SDL scalar enum types are generated as TypeScript union types:

Schema

module default {
  scalar type Status extending enum<Active, Inactive, Pending>;
};

Generated Type

/**
 * Status enum type from EdgeQL schema
 */
export type Status = "Active" | "Inactive" | "Pending";

Enum types do not generate Insert, Update, or FilterVars interfaces, and they do not produce query builders.

Query Builders

For each object type, codegen generates a query builder class with typed methods for common operations. Query builders are written to queries.ts.

Generated Class

/**
 * Query builder for User
 */
export class UserQueryBuilder {
  private static _typeCasts: Record<string, string> = {
    active: "<bool>",
    age: "<int32>",
    bio: "<str>",
    created_at: "<datetime>",
    email: "<str>",
    name: "<str>"
  };

  constructor(private client: DiscClient) {}

  /** Select all User objects */
  async select(shape?: string): Promise<Types.User[]> {
    const query = shape ? `select User ${shape}` : `select User { * }`;
    return await this.client.query<Types.User[]>(query);
  }

  /** Select User by ID */
  async selectById(id: string, shape?: string): Promise<Types.User | null> {
    const query = shape ?
      `select User ${shape} filter .id = <uuid>$id` :
      `select User { * } filter .id = <uuid>$id`;
    const results = await this.client.query<Types.User[]>(query, { id });

    return results[0] || null;
  }

  /** Filter User objects */
  async filter(filter: FilterArg<Types.UserFilter>): Promise<Types.User[]> {
    const compiled = compileFilter("User", filter, UserQueryBuilder._typeInfo);
    const shape = compiled.selectShape ?? "{ * }";
    const parts: string[] = [`select User ${shape}`];

    if (compiled.clause)
      parts.push(`filter ${compiled.clause}`);

    if (compiled.orderBy)
      parts.push(compiled.orderBy);

    if (compiled.limit !== null)
      parts.push(`limit ${compiled.limit}`);

    if (compiled.offset !== null)
      parts.push(`offset ${compiled.offset}`);

    return await this.client.query<Types.User[]>(
      parts.join(" "),
      compiled.variables
    );
  }

  /** Insert new User */
  async insert(data: Types.UserInsert): Promise<Types.User> {
    const assignments = Object
      .entries(data)
      .map(([key, value]) =>
        `${key} := ${UserQueryBuilder._typeCasts[key] || "<str>"}$${key}`
      )
      .join(", ");
    const query = `insert User { ${assignments} }`;

    return await this.client.query<Types.User>(query, data);
  }

  /** Update User by ID */
  async update(id: string, data: Types.UserUpdate): Promise<Types.User> {
    const assignments = Object
      .entries(data)
      .map(([key, value]) =>
        `${key} := ${UserQueryBuilder._typeCasts[key] || "<str>"}$${key}`
      )
      .join(", ");
    const query = `update User filter .id = <uuid>$id set { ${assignments} }`;

    return await this.client.query<Types.User>(query, { id, ...data });
  }

  /** Delete User by ID */
  async delete(id: string): Promise<Types.User> {
    const query = `delete User filter .id = <uuid>$id`;
    return await this.client.query<Types.User>(query, { id });
  }

  /** Count User objects */
  async count(
    condition?: string,
    variables?: Types.UserFilterVars
  ): Promise<number> {
    const query = condition ?
      `select count(User filter ${condition})` :
      `select count(User)`;

    return await this.client.query<number>(query, variables);
  }
}

Custom Shapes

The select and selectById methods accept an optional shape parameter to control which fields are returned (filter instead takes a select key in its filter object — see the Filter API):

// Select only email and name
const users = await client.user.select("{ email, name }");

// Select with nested links
const user = await client.user.selectById(
  id,
  `{
  email,
  name,
  posts: { created_at, title }
}`
);

When no shape is provided, { * } is used to select all scalar properties.

Filter Variable Types

FilterVars interfaces provide typed parameters for count operations. All fields are optional, and an index signature allows additional arbitrary parameters.

export interface UserFilterVars {
  active?: boolean;
  age?: number;
  bio?: string;
  created_at?: Date;
  email?: string;
  id?: string;
  name?: string;
  [key: string]: unknown;
}

Usage:

const activeUsers = await client.user.filter({
  active: true,
  age: { gt: 18 }
});

const count = await client.user.count(
  ".email LIKE <str>$pattern",
  { pattern: "%@example.com" }
);

JSDoc Annotations

The generator produces JSDoc comments on properties that carry constraints, readonly flags, or defaults from the schema. These appear in IDE tooltips and documentation tools.

Tags

Tag When Generated Example
@constraint <name> Property has a constraint @constraint exclusive
@constraint <name>(<args>) Constraint has arguments @constraint max_len_value(255)
@readonly Property has readonly := true @readonly
@default Property has a default expression @default
@description <text> Property has a description annotation @description User’s email address

Example Output

/**
 * str (required)
 * @readonly
 * @default
 * @constraint exclusive
 * @constraint max_len_value(255)
 */
email: string;

Type Cast Support

Query builders include a static _typeCasts map that maps property names to their EdgeQL type cast syntax. This ensures that insert and update operations use the correct EdgeQL type for each parameter.

private static _typeCasts: Record<string, string> = {
  active: "<bool>",
  age: "<int32>",
  created_at: "<datetime>",
  email: "<str>",
  name: "<str>"
};

The cast map is built from the edgeqlType field on each property definition. If edgeqlType is not available (older schemas), the raw type field is used. The full cast mapping covers all EdgeQL scalar types:

EdgeQL Type Cast Syntax
bigint <bigint>
bool <bool>
bytes <bytes>
cal::local_date <cal::local_date>
cal::local_datetime <cal::local_datetime>
cal::local_time <cal::local_time>
datetime <datetime>
decimal <decimal>
float64 <float64>
int32 <int32>
int64 <int64>
json <json>
str <str>
uuid <uuid>

Unknown types fall back to <typeName> (wrapping the type name in angle brackets).

Typed Client

The generated client.ts extends the SDK’s DiscClient base class and attaches query builder instances as properties:

import { DiscClient as BaseClient, type DiscClientConfig } from "./sdk/mod.ts";
import * as Queries from "./queries.ts";

/**
 * Type-safe Disc database client with query builders
 */
export class DiscClient extends BaseClient {
  /** Query builder for Post */
  readonly post: Queries.PostQueryBuilder;
  /** Query builder for User */
  readonly user: Queries.UserQueryBuilder;

  constructor(config?: DiscClientConfig) {
    super(config);

    this.post = new Queries.PostQueryBuilder(this);
    this.user = new Queries.UserQueryBuilder(this);
  }
}

Usage:

import { DiscClient } from "./dbschema/disc-client/index.ts";

// baseUrl is resolved from your project's disc.toml when omitted.
const client = new DiscClient();

// Fully typed queries via builders
const users = await client.user.select();
const user = await client.user.selectById("some-uuid");
const newUser = await client.user.insert({ email: "a@b.com", name: "Ada" });
await client.user.update(newUser.id, { name: "Ada B." });
await client.user.delete(newUser.id);

Multi-Module Schemas

When your schema uses multiple modules, codegen generates TypeScript namespaces to preserve module boundaries.

Schema

module default {
  type Merchant {
    multi api_keys: api::ApiKey;
    required name: str;
    multi payments: payment::Payment;
  };
};

module api {
  type ApiKey {
    required key: str;
    required merchant: default::Merchant;
  };
};

module payment {
  type Payment {
    required amount: decimal;
    required merchant: default::Merchant;
  };
};

Generated Types (interfaces.ts)

export namespace $default {
  export interface Merchant {
    /** Link to ApiKey (many) */
    api_keys?: api.ApiKey[];
    /** Unique identifier */
    id: string;
    /** str (required) */
    name: string;
    /** Link to Payment (many) */
    payments?: payment.Payment[];
  }

  export interface MerchantInsert {
    name: string;
  }

  export interface MerchantUpdate {
    name?: string;
  }

  export interface MerchantFilterVars {
    id?: string;
    name?: string;
    [key: string]: unknown;
  }
}

export namespace api {
  export interface ApiKey {
    /** Unique identifier */
    id: string;
    /** str (required) */
    key: string;
    /** Link to Merchant (one, required) */
    merchant: $default.Merchant;
  }

  // ... Insert, Update, FilterVars ...
}

export namespace payment {
  export interface Payment {
    /** decimal (required) */
    amount: number;
    /** Unique identifier */
    id: string;
    /** Link to Merchant (one, required) */
    merchant: $default.Merchant;
  }

  // ... Insert, Update, FilterVars ...
}

Cross-module links resolve to their namespaced type (e.g., api.ApiKey, $default.Merchant). The default module uses the namespace name $default because default is a reserved word in TypeScript.

Multi-Module Query Builders

Query builders for non-default modules use qualified EdgeQL type names:

// For payment::Payment
const query = `select payment::Payment { * }`;

// Type references use namespace prefix
async select(shape?: string): Promise<Types.payment.Payment[]> { ... }

Configuration

The full CodegenConfig interface:

interface CodegenConfig {
  formatOutput: boolean; // Clean up generated code formatting
  includeClient: boolean; // Generate typed client class
  includeMutations: boolean; // Generate mutation helpers
  includeQueryBuilders: boolean; // Generate query builder classes
  interfaceSuffix?: string; // Suffix for generated interface names
  outputDir: string; // Output directory (default: "./dbschema/disc-client")
  schemaDir?: string; // Directory to scan for schema files
  schemaSource: string; // SDL schema file path (default: "./schema.disc")
  target: "client" | "server" | "both"; // Generation target
  typePrefix?: string; // Prefix for generated type names (e.g., "Db")
}

Preset Configurations

import { DEFAULT_CONFIGS } from "disc/codegen/mod.ts";

// Client-side: query builders + client + mutations
const clientConfig = DEFAULT_CONFIGS.client();

// Server-side: types only, no query builders or client
const serverConfig = DEFAULT_CONFIGS.server();

// Both: everything
const bothConfig = DEFAULT_CONFIGS.both();

Target Modes

Target types.ts queries.ts client.ts index.ts
client Yes Yes Yes Yes
server Yes No No Yes
both Yes Yes Yes Yes

The server target generates only type definitions, which is useful when you need the types for validation or serialization but do not need query builders.

Schema File Discovery

When running disc codegen, the generator searches for schema files in this order:

  1. .disc files -- Disc-native schema format.
  2. .gel files -- Gel-compatible schema format (if no .disc files found).
  3. .esdl files -- Legacy EdgeDB/Gel format (if no .gel files found).

Files are sorted alphabetically within each format. Only one format is used per project (the first one found).

Utility Types

In addition to per-type interfaces, codegen produces utility types used by the query builders:

/** Query result wrapper */
export interface QueryResult<T> {
  data: T;
  extensions?: {
    durationMs?: number;
    queryHash?: string;
    sql?: string;
  };
}

/** Query error */
export interface QueryError {
  extensions?: Record<string, any>;
  locations?: Array<{ column: number; line: number; }>;
  message: string;
  path?: Array<string | number>;
}

Regenerating After Schema Changes

Run disc codegen any time your schema changes. The generated files are fully overwritten on each run. Do not edit them manually -- your changes will be lost.

A typical workflow:

  1. Edit your .disc schema file.
  2. Run disc migrate to apply the schema change.
  3. Run disc codegen to regenerate TypeScript types.
  4. Commit all three: the schema file, migration state, and generated types.

If you use disc watch during development, both migrations and codegen can run automatically when schema files change.

Architecture & Language Targets

Codegen runs on a language-neutral intermediate representation (IR). The schema is transformed once into the IR, and one emitter per target language turns the IR into source — so everything above (interfaces, insert/update/filter types, query builders, the typed client) is produced by the TypeScript emitter consuming that IR. Adding a language is "write one emitter," no change to the schema analysis.

schema --> schemaToIR() --> IR --> emitTypeScript() (the output documented above)
                               --> emitRust()       (a Cargo crate: structs, query builders, std-only HTTP/JSON client)
                               --> emitGo()         (a Go package: structs, query builders, stdlib HTTP/JSON client)

disc codegen emits TypeScript; --rust and --go emit a Rust client crate (./dbschema/disc-client-rust) or a Go client package (./dbschema/disc-client-go) instead. All three are available programmatically via generateRust / generateGo (and the lower-level emitRust / emitGo), each producing a self-contained, dependency-light client that maps cardinality faithfully (Rust One -> T / AtMostOne -> Option<T> / Many -> Vec<T>; Go One -> T / AtMostOne -> *T / Many -> []T) and talks to the same HTTP /query endpoint as the TypeScript client. The --no-queries / --no-client / --no-mutations toggles apply to every target.