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.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 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 */
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);
}
}
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." });
await client.user.delete(newUser.id);
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) and talks to the same HTTP /query endpoint as the TypeScript client. The --no-queries / --no-client / --no-mutations toggles apply to every target.