Skip to content

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.

Terminal window
# npm
npm install fraiseql
# pnpm (recommended)
pnpm add fraiseql
# yarn
yarn add fraiseql
# bun
bun add fraiseql

Requirements: Node.js 18+, TypeScript 5.0+, version 2.1.0

Enable decorators in your tsconfig.json. Note: emitDecoratorMetadata is not required — FraiseQL uses explicit field registration rather than reflection metadata (see Registering Fields):

tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"strict": true
}
}

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.

APIGraphQL EquivalentPurpose
@Type()typeMark a class as a GraphQL output type
registerTypeFields()Register fields for a @Type() class
input()inputDefine a GraphQL input type (imperative)
enum_()enumDefine a GraphQL enum (imperative)
interface_()interfaceDefine a GraphQL interface (imperative)
union()unionDefine a GraphQL union (imperative)
@Query()Query fieldWire a query to a SQL view (method decorator)
registerQuery()Register a query imperatively
@Mutation()Mutation fieldWire a mutation to a SQL function (method decorator)
registerMutation()Register a mutation imperatively
@Subscription()Subscription fieldDeclare a real-time subscription (method decorator)
registerSubscription()Register a subscription imperatively
@Scalar()scalarRegister a custom scalar class
exportSchema()Write schema.json

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:

schema.ts
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 },
]);

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
},
]);

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';

input() is an imperative function — it is not a class decorator. Pass the input name, field array, and optional config:

schema.ts
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 },
]);

@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:

schema.ts
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;
}
}

Query arguments matching columns in the backing view become SQL WHERE clauses automatically. See SQL Patterns → Automatic WHERE Clauses for details.


Use @Mutation() on class methods or registerMutation() imperatively:

schema.ts
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;
}
}

Mutations wire to PostgreSQL functions returning mutation_response. See SQL Patterns → Mutation Functions for the complete function template.


Use @Subscription() on class methods or registerSubscription() imperatively. Subscriptions are compiled projections of database events sourced from LISTEN/NOTIFY — not resolver-based:

schema.ts
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' }
);

Extend CustomScalar and use @Scalar() to register custom scalar types:

schema.ts
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 },
]);

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' });
import { union } from 'fraiseql';
const SearchResult = union('SearchResult', ['User', 'Post', 'Comment'], {
description: 'Result of a search query',
});

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');

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 + GraphQL
registerQuery(
'posts', 'Post', true, false,
[{ name: 'limit', type: 'Int', nullable: false, default: 20 }],
'Fetch posts',
{ sqlSource: 'v_post', restPath: '/posts', restMethod: 'GET' }
);
// With path parameter
registerQuery(
'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 mutation
registerMutation(
'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.


  1. Export the schema from your TypeScript definitions:

    Terminal window
    npx tsx schema.ts

    This writes schema.json containing the compiled type registry.

  2. 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.json

    The --database flag enables three-level validation — checking that sql_source views exist, columns match, and JSONB keys are present. Omit it to compile without database access.

  3. Serve the API:

    Terminal window
    fraiseql run

    Expected output:

    ✓ FraiseQL 2.1.0 running on http://localhost:8080/graphql