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
bigint bigint bigint | null bigint[]
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 string string | null string[]
cal::local_time string string | null string[]
cal::relative_duration string string | null string[]
datetime Date Date | null Date[]
decimal string string | null string[]
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 and bigint map to bigint (not number, which is what Gel’s JS client reads an int64 as) and decimal to string, so values beyond what a double holds survive without precision loss. A bigint works wherever a query builder takes a value (insert and update data, filter values), and you can pass bigint values straight back as raw 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, bigint and decimal are JSON numbers carrying every digit, as in Gel. The client reads a number a double can’t hold exactly as a string of its digits, and every other number as a plain number. The generated builders then turn int64 and bigint fields into bigint and decimal fields into a string of digits, in every row select, selectById, filter, insert and update return: in arrays and multi properties, inside tuples and named tuples, on linked objects and in link properties ("@weight"). reviveTyped does the same for datetime, which becomes a Date wherever it sits, arrays and tuples included. The cal:: local types (cal::local_date, cal::local_time, cal::local_datetime) and the durations stay the strings they arrive as, in Gel’s ISO 8601 form (PT1H2M, P1Y2M3DT4H, P0D): they have no time zone, and a Date would pin them to the reader’s. Raw client.query() results are not revived by type: there an int64 is a number, or a numeric string past Number.MAX_SAFE_INTEGER, which { revive: true } (or parseInt64) turns into a bigint. The Go client types bigint and decimal as json.Number and the Rust client as ExactNumber (a serde_json::Number under arbitrary_precision), both exact in each direction.

decimal and bigint never hold NaN or ±Infinity: a cast or write that would produce one fails with an invalid-value error, as in Gel, and bigint also rejects fractions from strings (a decimal or float cast to bigint rounds). float32/float64 do hold them; JSON has no number for them, so they travel as the strings "NaN", "Infinity" and "-Infinity". The Go and Rust clients read and write those into their float fields; in TypeScript they arrive as those strings.

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: a multi link to User is User[]. A single link selected with a sub-shape arrives as a one-element array of its row, or null when an optional one is empty, so it is declared author: [User] (optional: editor?: [User] | null) — read post.author[0].name, not post.author.name. This differs from Gel, which returns the object itself. Without a sub-shape a single link is its target’s id.

A schema computed is typed by what its expression yields, as Gel infers it — not only a path (auth := .author) but an aggregate, an operator or a built-in call. count(…) is a required int64 (bigint); ++ is required only when every operand is (full := .nick ++ " " ++ .name is full?: string | null when nick is optional); min/max are required only when their argument is; math::mean and the other averages are float64; a comparison is a bool; array_agg is a required array. A declared single or multi sets the cardinality (single first_post := (select … limit 1) is first_post?: [Post] | null). The builders revive these like stored properties — an int64 to bigint, a datetime to Date. A call to a function the schema declares is typed by its declared return type, optional and set of included (-> set of str is name_parts?: string[] | null). The function itself gets no client method: call it in a query (see Functions → SDL Functions).

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: without a shape, `id` and the stored properties */
  select(): Promise<Types.UserRow[]>;
  select(shape: string): Promise<Types.User[]>;
  async select(shape?: string): Promise<Types.UserRow[] | Types.User[]> {
    const query = shape ? `select User ${shape}` : `select User { * }`;
    return await this.client.query<Types.UserRow[] | Types.User[]>(query);
  }

  /** Select User by ID: without a shape, `id` and the stored properties */
  selectById(id: string): Promise<Types.UserRow | null>;
  selectById(id: string, shape: string): Promise<Types.User | null>;
  async selectById(id: string, shape?: string): Promise<Types.UserRow | 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.UserRow | Types.User)[]>(query, { id });

    return results[0] || null;
  }

  /** Filter User objects: without a `select`, `id` and the stored properties */
  filter(filter: FilterArg<Types.UserFilter> & { select?: undefined }): Promise<Types.UserRow[]>;
  filter(filter: FilterArg<Types.UserFilter>): Promise<Types.User[]>;
  async filter(filter: FilterArg<Types.UserFilter>): Promise<Types.UserRow[] | 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.UserRow[] | Types.User[]>(
      parts.join(" "),
      compiled.variables
    );
  }

  /** Insert new User */
  async insert(data: Types.UserInsert): Promise<Types.UserMutationResult> {
    const assignments = Object
      .entries(data)
      .map(([key, value]) =>
        `${key} := ${UserQueryBuilder._typeCasts[key] || "<str>"}$${key}`
      )
      .join(", ");
    const query = `insert User { ${assignments} }`;
    // The server answers with the set of rows the insert wrote: one.
    const [row] = await this.client.query<Types.UserMutationResult[]>(query, data);

    return row;
  }

  /** Update User by ID */
  async update(id: string, data: Types.UserUpdate): Promise<Types.UserMutationResult | { updated: 0 }> {
    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} }`;
    // One row, or none for an id that doesn’t exist.
    const [row] = await this.client.query<Types.UserMutationResult[]>(query, { id, ...data });

    return row ?? { updated: 0 };
  }

  /** Delete User by ID. Resolves to the affected-row count, `{ deleted: 0 | 1 }`. */
  async delete(id: string): Promise<{ deleted: number }> {
    const query = `delete User filter .id = <uuid>$id`;
    const rows = await this.client.query<unknown[]>(query, { id });

    return { deleted: rows.length };
  }

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

    return count;
  }
}

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." });
const { deleted } = await client.user.delete(newUser.id); // { deleted: 0 | 1 }

insert() and update() resolve to a <Type>MutationResult: the stored row as written — id, every stored property, and each single link as its target’s id (string, or null when an optional one is unset). Multi links, link properties and computed fields are not returned. update() of an id that doesn’t exist resolves to { updated: 0 }, so check for it before reading the row. The Rust and Go clients return the same <Type>MutationResult struct — update as Option<…> in Rust and a pointer in Go, None/nil for an id that doesn’t exist — and delete a DeleteResult. (Over the wire every mutation answers with the set of rows it wrote; the builders unwrap it — see Server → POST /query.)

Without a shape, select(), selectById(id) and filter() without a select key resolve to <Type>Row: id and every stored property, as { * } returns them — no links or computed fields. With a shape they resolve to the full <Type> interface.

delete() resolves to the affected-row count, not the deleted object; to read the row back, use raw EdgeQL:

client.query("select (delete User filter .id = <uuid>$id) { id, email }", { id });

A duplicate on an exclusive constraint in insert()/update() throws UniqueViolationError; every non-OK response is an error (see Client SDK → Error Handling). The typed client is a DiscClient subclass, so client.withToken(serviceToken) returns a typed client with its builders intact.

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; a single link, which arrives as a one-element array, is Vec<T> / Option<Vec<T>> in Rust and []T in Go) and talks to the same HTTP /query endpoint as the TypeScript client. The --no-queries / --no-client / --no-mutations toggles apply to every target.