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 codegenemits TypeScript by default;disc codegen --rustemits a Cargo crate anddisc codegen --goemits 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)) wheredefineSchema()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 underlyingclient.query()runtime; pick whichever fits your build pipeline.
Related documentation: Schema | Client SDK | EdgeQL
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.
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.
disc codegen --output ./src/generated/
Writes generated files to the specified directory. The directory is created if it does not exist.
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`);
}
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
*/
Each object type in your schema becomes a TypeScript interface. Each property and link is mapped to the appropriate TypeScript type.
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;
};
};
/**
* 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:
id property is always present (auto-generated UUID).? suffix. Optional properties have ? and include | null.extends in the interface declaration.bytes properties are real Uint8Arrays at runtime: the generated builders pass Uint8Array (or Node Buffer) values through as base64 on the wire and revive bytes fields in results — including through links — with reviveTyped(data, Builder._typeInfo). See Client SDK → Bytes.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 are smart subsets of the full interface, designed for creating new objects. They exclude properties that should not or cannot be set during insertion.
| 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.
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 exclude properties that cannot be modified after creation. All remaining properties are optional since you typically update only a subset of fields.
| 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). |
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.
SDL scalar enum types are generated as TypeScript union types:
module default {
scalar type Status extending enum<Active, Inactive, Pending>;
};
/**
* 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.
For each object type, codegen generates a query builder class with typed methods for common operations. Query builders are written to queries.ts.
/**
* 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;
}
}
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.
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" }
);
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.
| 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 |
/**
* str (required)
* @readonly
* @default
* @constraint exclusive
* @constraint max_len_value(255)
*/
email: string;
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).
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.
When your schema uses multiple modules, codegen generates TypeScript namespaces to preserve module boundaries.
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;
};
};
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.
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[]> { ... }
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")
}
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 | 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.
When running disc codegen, the generator searches for schema files in this order:
.disc files -- Disc-native schema format..gel files -- Gel-compatible schema format (if no .disc files found)..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).
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>;
}
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:
.disc schema file.disc migrate to apply the schema change.disc codegen to regenerate TypeScript types.If you use disc watch during development, both migrations and codegen can run automatically when schema files change.
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.