TypeScript SDK
The FraiseQL TypeScript SDK is a compile-time schema authoring SDK: you define GraphQL types, queries, and mutations in TypeScript using decorators, and the FraiseQL compiler generates an optimized GraphQL API backed by your SQL views. There is no runtime FFI — decorators output JSON only. Function bodies are never executed.
Installation
Section titled “Installation”# npmnpm install fraiseql
# pnpm (recommended)pnpm add fraiseql
# yarnyarn add fraiseql
# bunbun add fraiseqlRequirements: Node.js 18+, TypeScript 5.0+, version 2.1.0
TypeScript Configuration
Section titled “TypeScript Configuration”Enable decorators in your tsconfig.json. Note: emitDecoratorMetadata is not required — FraiseQL uses explicit field registration rather than reflection metadata (see Registering Fields):
{ "compilerOptions": { "target": "ES2022", "experimentalDecorators": true, "strict": true }}Core Concepts
Section titled “Core Concepts”FraiseQL TypeScript provides PascalCase decorator functions and imperative registration helpers. All decorators and helpers register metadata in a schema registry for JSON export — no runtime behavior is added.
| API | GraphQL Equivalent | Purpose |
|---|---|---|
@Type() | type | Mark a class as a GraphQL output type |
registerTypeFields() | — | Register fields for a @Type() class |
input() | input | Define a GraphQL input type (imperative) |
enum_() | enum | Define a GraphQL enum (imperative) |
interface_() | interface | Define a GraphQL interface (imperative) |
union() | union | Define a GraphQL union (imperative) |
@Query() | Query field | Wire a query to a SQL view (method decorator) |
registerQuery() | — | Register a query imperatively |
@Mutation() | Mutation field | Wire a mutation to a SQL function (method decorator) |
registerMutation() | — | Register a mutation imperatively |
@Subscription() | Subscription field | Declare a real-time subscription (method decorator) |
registerSubscription() | — | Register a subscription imperatively |
@Scalar() | scalar | Register a custom scalar class |
exportSchema() | — | Write schema.json |
Defining Types
Section titled “Defining Types”Registering Fields
Section titled “Registering Fields”TypeScript does not preserve property type information at runtime by default. Because of this limitation, @Type() registers an empty type shell, and you must call registerTypeFields() immediately after to provide field metadata:
import { Type, registerTypeFields } from 'fraiseql';import type { ID, Email, Slug, DateTime } from 'fraiseql';
@Type()class User {}
registerTypeFields('User', [ { name: 'id', type: 'ID', nullable: false }, { name: 'username', type: 'String', nullable: false }, { name: 'email', type: 'Email', nullable: false }, { name: 'bio', type: 'String', nullable: true }, { name: 'createdAt', type: 'DateTime', nullable: false },]);
@Type()class Post {}
registerTypeFields('Post', [ { name: 'id', type: 'ID', nullable: false }, { name: 'title', type: 'String', nullable: false }, { name: 'slug', type: 'Slug', nullable: false }, { name: 'content', type: 'String', nullable: false }, { name: 'isPublished', type: 'Boolean', nullable: false }, { name: 'createdAt', type: 'DateTime', nullable: false }, { name: 'updatedAt', type: 'DateTime', nullable: false }, { name: 'author', type: 'User', nullable: false },]);
@Type()class Comment {}
registerTypeFields('Comment', [ { name: 'id', type: 'ID', nullable: false }, { name: 'content', type: 'String', nullable: false }, { name: 'createdAt', type: 'DateTime', nullable: false }, { name: 'author', type: 'User', nullable: false },]);Field-Level Access Control
Section titled “Field-Level Access Control”Pass requiresScope and onDeny in the field definition to restrict access by JWT scope:
registerTypeFields('User', [ { name: 'id', type: 'ID', nullable: false }, { name: 'username', type: 'String', nullable: false }, { name: 'salary', type: 'Decimal', nullable: false, requiresScope: 'hr:view_pii', // query fails with FORBIDDEN if scope is absent }, { name: 'internalNotes', type: 'String', nullable: true, requiresScope: 'admin:read', onDeny: 'mask', // returns null instead of failing },]);Built-in Scalars
Section titled “Built-in Scalars”Scalar types are exported as TypeScript type aliases from the main fraiseql package:
import type { ID, // UUID v4 — use for all `id` fields and foreign key references Email, // RFC 5322 validated email address Slug, // URL-safe slug DateTime, // ISO 8601 datetime Date, // ISO 8601 date URL, // RFC 3986 validated URL PhoneNumber, // E.164 phone number Json, // Arbitrary JSON — maps to PostgreSQL JSONB Decimal, // Precise decimal for financial values} from 'fraiseql';Defining Inputs
Section titled “Defining Inputs”input() is an imperative function — it is not a class decorator. Pass the input name, field array, and optional config:
import { input } from 'fraiseql';
const CreateUserInput = input('CreateUserInput', [ { name: 'username', type: 'String', nullable: false }, { name: 'email', type: 'Email', nullable: false }, { name: 'bio', type: 'String', nullable: true },], { description: 'Input for creating a new user' });
const CreatePostInput = input('CreatePostInput', [ { name: 'title', type: 'String', nullable: false }, { name: 'content', type: 'String', nullable: false }, { name: 'authorId', type: 'ID', nullable: false }, { name: 'isPublished', type: 'Boolean', nullable: false, default: false },], { description: 'Input for creating a new blog post' });
const UpdatePostInput = input('UpdatePostInput', [ { name: 'id', type: 'ID', nullable: false }, { name: 'title', type: 'String', nullable: true }, { name: 'content', type: 'String', nullable: true }, { name: 'isPublished', type: 'Boolean', nullable: true },]);Defining Queries
Section titled “Defining Queries”@Query() and @Mutation() are method decorators — they must be applied to methods on a class, not to standalone function declarations. The imperative registerQuery() alternative works without a class:
import { Query } from 'fraiseql';
class Queries { @Query({ sqlSource: 'v_post' }) posts(isPublished?: boolean, limit = 20): Post[] { // Body never executed — compile-time schema definition only return []; }
@Query({ sqlSource: 'v_post' }) post(id: string): Post | null { return null; }}import { registerQuery } from 'fraiseql';
registerQuery( 'posts', // GraphQL field name 'Post', // return type true, // returnsList false, // nullable [ { name: 'isPublished', type: 'Boolean', nullable: true }, { name: 'limit', type: 'Int', nullable: false, default: 20 }, { name: 'offset', type: 'Int', nullable: false, default: 0 }, ], 'Fetch posts, optionally filtered by published status', { sqlSource: 'v_post' });
registerQuery( 'post', 'Post', false, // single item true, // nullable — returns null when not found [{ name: 'id', type: 'ID', nullable: false }], 'Fetch a single post by ID', { sqlSource: 'v_post' });How Arguments Become WHERE Clauses
Section titled “How Arguments Become WHERE Clauses”Query arguments matching columns in the backing view become SQL WHERE clauses automatically. See SQL Patterns → Automatic WHERE Clauses for details.
Defining Mutations
Section titled “Defining Mutations”Use @Mutation() on class methods or registerMutation() imperatively:
import { Mutation } from 'fraiseql';
class Mutations { @Mutation({ sqlSource: 'fn_create_user', operation: 'CREATE' }) createUser(username: string, email: string): User { // Body never executed — compile-time schema definition only return {} as User; }
@Mutation({ sqlSource: 'fn_create_post', operation: 'CREATE' }) createPost(input: string): Post { // input type registered via input() return {} as Post; }
@Mutation({ sqlSource: 'fn_delete_post', operation: 'DELETE' }) deletePost(id: string): Post { return {} as Post; }}import { registerMutation } from 'fraiseql';
registerMutation( 'createUser', 'User', false, // returnsList false, // nullable [ { name: 'username', type: 'String', nullable: false }, { name: 'email', type: 'Email', nullable: false }, ], 'Create a new user account', { sqlSource: 'fn_create_user', operation: 'CREATE' });
registerMutation( 'createPost', 'Post', false, false, [{ name: 'input', type: 'CreatePostInput', nullable: false }], 'Create a new blog post', { sqlSource: 'fn_create_post', operation: 'CREATE' });Backing SQL Function Shape
Section titled “Backing SQL Function Shape”Mutations wire to PostgreSQL functions returning mutation_response. See SQL Patterns → Mutation Functions for the complete function template.
Subscriptions
Section titled “Subscriptions”Use @Subscription() on class methods or registerSubscription() imperatively. Subscriptions are compiled projections of database events sourced from LISTEN/NOTIFY — not resolver-based:
import { registerSubscription } from 'fraiseql';
registerSubscription( 'orderCreated', 'Order', // entity type false, // nullable [ { name: 'userId', type: 'ID', nullable: true }, ], 'Subscribe to new orders', { topic: 'order_events', operation: 'CREATE' });Custom Scalars
Section titled “Custom Scalars”Extend CustomScalar and use @Scalar() to register custom scalar types:
import { Scalar, Type, registerTypeFields } from 'fraiseql';import { CustomScalar } from 'fraiseql';
@Scalar()class SlackUserId extends CustomScalar { name = 'SlackUserId';
serialize(value: unknown): string { return String(value); }
parseValue(value: unknown): string { const s = String(value); if (!s.startsWith('U') || s.length !== 11) { throw new Error(`Invalid Slack user ID: ${s}`); } return s; }
parseLiteral(ast: unknown): string { if (ast && typeof ast === 'object' && 'value' in ast) { return this.parseValue((ast as { value: unknown }).value); } throw new Error('Invalid Slack user ID literal'); }}
@Type()class SlackIntegration {}
registerTypeFields('SlackIntegration', [ { name: 'id', type: 'ID', nullable: false }, { name: 'slackUserId', type: 'SlackUserId', nullable: false }, { name: 'workspace', type: 'String', nullable: false },]);Enums and Unions
Section titled “Enums and Unions”Use enum_() (note the trailing underscore — enum is a reserved TypeScript keyword):
import { enum_ } from 'fraiseql';
const PostStatus = enum_('PostStatus', { DRAFT: 'draft', PUBLISHED: 'published', ARCHIVED: 'archived',}, { description: 'Publication status of a post' });Unions
Section titled “Unions”import { union } from 'fraiseql';
const SearchResult = union('SearchResult', ['User', 'Post', 'Comment'], { description: 'Result of a search query',});Complete example
Section titled “Complete example”For a complete blog API schema combining all the patterns above, see the fraiseql-starter-blog repository. To export the compiled schema:
exportSchema('schema.json');Transport Annotations
Section titled “Transport Annotations”REST annotations (restPath/restMethod) are available in the TypeScript SDK as of v2.1.0.
Transport annotations are optional. Omit them to serve an operation via GraphQL only. Add restPath/restMethod to the config object of registerQuery(), registerMutation(), @Query(), and @Mutation() to also expose an operation as a REST endpoint. gRPC endpoints are auto-generated when [grpc] is enabled — no per-operation annotation needed. See gRPC Transport.
import { registerQuery, registerMutation } from 'fraiseql';
// REST + GraphQLregisterQuery( 'posts', 'Post', true, false, [{ name: 'limit', type: 'Int', nullable: false, default: 20 }], 'Fetch posts', { sqlSource: 'v_post', restPath: '/posts', restMethod: 'GET' });
// With path parameterregisterQuery( 'post', 'Post', false, true, [{ name: 'id', type: 'ID', nullable: false }], 'Fetch a single post by ID', { sqlSource: 'v_post', restPath: '/posts/{id}', restMethod: 'GET' });
// REST mutationregisterMutation( 'createPost', 'Post', false, false, [{ name: 'input', type: 'CreatePostInput', nullable: false }], 'Create a new blog post', { sqlSource: 'fn_create_post', operation: 'CREATE', restPath: '/posts', restMethod: 'POST' });Path parameters in restPath (e.g., {id}) must match argument names exactly. A mismatch produces a compile-time error. Duplicate (method, path) pairs are also rejected at compile time.
Build and Serve
Section titled “Build and Serve”-
Export the schema from your TypeScript definitions:
Terminal window npx tsx schema.tsThis writes
schema.jsoncontaining the compiled type registry. -
Compile (and optionally validate against the database):
Terminal window fraiseql compile fraiseql.toml --database "$DATABASE_URL"Expected output:
✓ Schema compiled: 3 types, 2 queries, 3 mutations✓ Database validation passed: all relations, columns, and JSON keys verified.✓ Build complete: schema.compiled.jsonThe
--databaseflag enables three-level validation — checking thatsql_sourceviews exist, columns match, and JSONB keys are present. Omit it to compile without database access. -
Serve the API:
Terminal window fraiseql runExpected output:
✓ FraiseQL 2.1.0 running on http://localhost:8080/graphql
Next Steps
Section titled “Next Steps”- SDK Overview — how compile-time authoring works
- SQL Patterns — view and function conventions
- Your First API — full tutorial
- All SDKs — compare languages