Type System
Type System — Type definitions
FraiseQL uses decorators (Python) and decorator functions (TypeScript) to define GraphQL schema elements. This reference covers all available decorators, the GraphQL SDL they generate, and how to handle errors within decorated functions.
When a request arrives, FraiseQL processes it through a fixed pipeline:
Request → Middleware (in registration order) → Authentication (JWT/API key verification) → Authorization (role + scope checks) → Resolver (SQL view or function execution) → ResponseWhen multiple decorators protect the same resolver, they execute in this order:
@authenticated — JWT validation (fails fast if not logged in)@requires_scope — OAuth scope check@role — Role-based access check@after_mutation or @observer — Post-execution side effectsNote: @before_mutation exceptions abort the mutation and roll back the database transaction.
This means:
@middleware functions run before any authentication check — they can read or modify the raw request@query/@mutation requires_scope checks run after the token is verified@before_mutation hooks run inside the resolver phase, after all auth checks pass@after_mutation hooks run after the SQL function completes successfullyDefines a GraphQL object type.
import fraiseql
@fraiseql.typeclass User: """A user in the system.""" id: ID name: str email: str created_at: DateTimeimport { Type } from 'fraiseql';
@Type()class User { /** A user in the system. */ id!: ID; name!: string; email!: string; createdAt!: Date;}Generated GraphQL SDL:
type User { """A user in the system.""" id: ID! name: String! email: String! createdAt: DateTime!}| Option | Type | Description |
|---|---|---|
implements | list[str] | Interfaces to implement |
relay | bool | Implement the Node interface and enable keyset cursor generation |
requires_role | str | Required role to access this type |
crud | bool or list[str] | Auto-generate CRUD queries/mutations. True = all ops, or list: ["create", "read", "list", "update", "delete"] |
tenant_scoped | bool | Auto-inject tenant_id from JWT on all queries/mutations for this type |
sql_source | str | Override the default view name (default: v_{snake_case_name}) |
key_fields | list[str] | Federation key fields for entity resolution. When export_schema(federation=...) is used, types without explicit key_fields default to ["id"] |
extends | bool | Marks this type as extending a type defined in another subgraph (Apollo Federation extend type) |
When relay=True, the type implements the Node interface, the global node(id: ID!) query is added to the schema, and fraiseql compile validates that pk_{entity} is present in the corresponding SQL view.
@fraiseql.type(implements=["Node"])class UserAnalytics: id: ID page_views: int@Type({ implements: ['Node'] })class UserAnalytics { id!: ID; pageViews!: number;}Use Annotated (Python) or field decorators (TypeScript) for field-level options:
from typing import Annotated
@fraiseql.typeclass User: id: ID email: str
# Protected field — hidden or rejected if scope missing salary: Annotated[Decimal, fraiseql.field(requires_scope="hr:read", on_deny="mask")]
# Deprecated field username: Annotated[str, fraiseql.field(deprecated="Use email instead")]
# Field with description bio: Annotated[str | None, fraiseql.field(description="User biography")]import { Type, field } from 'fraiseql';
@Type()class User { id!: ID; email!: string;
@field({ requiresScope: 'hr:read' }) salary!: number;
@field({ deprecated: 'Use email instead' }) username!: string;}Defines a GraphQL query.
@fraiseql.querydef user(id: ID) -> User | None: """Get a user by ID.""" return fraiseql.config(sql_source="v_user")import { Query, ID } from 'fraiseql';
@Query({ sqlSource: 'v_user' })function user(id: ID): Promise<User | null> { return Promise.resolve(null);}Generated GraphQL SDL:
type Query { user(id: ID!): User}| Option | Type | Default | Description |
|---|---|---|---|
sql_source | str | — | View or table to query |
relay | bool | False | Enable Relay spec (Connection/Edge/PageInfo, keyset cursors) |
auto_params | bool | dict | inferred | True = all four params; False = none; dict for partial overrides (e.g. {"limit": True, "offset": False}). Partial dicts merge with [query_defaults] in fraiseql.toml. |
inject | dict[str, str] | — | Server-side JWT claim injection. Keys are SQL param names; values are "jwt:<claim>". See Server-Side Injection. |
cache_ttl_seconds | int | — | Cache duration in seconds. Set to 0 to disable caching for this query |
additional_views | list[str] | None | Additional SQL views this query reads from. Used for cache invalidation — mutations to these views will also evict this query’s cache entries. |
requires_role | str | — | Required role |
requires_scope | str | — | Required scope |
rest_path | str | None | None | REST endpoint path override (e.g. "/users/{id}"). When absent, REST auto-derives endpoints from query names. Path parameter names must match function argument names. |
rest_method | str | None | "GET" | HTTP method override for the REST endpoint. Defaults to "GET" for queries. |
Transport notes:
[rest] is enabled. Use rest_path/rest_method only to override the default path or method.[grpc] is enabled — no per-operation annotation needed. Filter with include_types/exclude_types in [grpc].rest_path must match function argument names — mismatches are caught at compile time.(method, path) pairs are rejected at compile time.# Custom REST path (overrides auto-derived /users/{id})@fraiseql.query(rest_path="/api/users/{id}", rest_method="GET")def get_user(id: UUID) -> User: return fraiseql.config(sql_source="v_user")
@fraiseql.mutation( sql_source="create_user", operation="CREATE", rest_path="/api/users", rest_method="POST",)def create_user(email: str, name: str) -> User: ...@Query({ sqlSource: 'v_user', restPath: '/api/users/{id}', restMethod: 'GET',})async function getUser(id: string): Promise<User> {}For list queries (-> list[T]), all four params are enabled automatically. Pass auto_params=False to opt out:
| Param | GraphQL argument | SQL |
|---|---|---|
where | where: {Type}WhereInput | WHERE … |
orderBy | orderBy: [{Type}OrderByInput!] | ORDER BY … |
limit | limit: Int | LIMIT … |
offset | offset: Int | OFFSET … |
# All four auto-enabled (list return type)@fraiseql.querydef posts() -> list[Post]: return fraiseql.config(sql_source="v_post")
# Opt out entirely@fraiseql.query(auto_params=False)def posts() -> list[Post]: return fraiseql.config(sql_source="v_post")When relay=True, the query generates a Relay-spec {Type}Connection instead of [Type!]!, and limit/offset are replaced by first/after/last/before. Requires @fraiseql.type(relay=True) on the return type and pk_{entity} in the SQL view.
@fraiseql.type(relay=True)class Post: id: ID title: str
@fraiseql.querydef posts() -> list[Post]: return fraiseql.config(sql_source="v_post", relay=True)See Relay Pagination for full details.
Defines a GraphQL mutation.
@fraiseql.mutation(sql_source="fn_create_user", operation="CREATE")def create_user(email: str, name: str) -> User: """Create a new user.""" passimport { Mutation } from 'fraiseql';
@Mutation({ sqlSource: 'fn_create_user', operation: 'CREATE' })function createUser(email: string, name: string): Promise<User> { return Promise.resolve({} as User);}Generated GraphQL SDL:
type Mutation { createUser(email: String!, name: String!): User!}| Option | Type | Default | Description |
|---|---|---|---|
sql_source | str | — | SQL function to call |
operation | str | — | "CREATE", "UPDATE", "DELETE", "CUSTOM" |
inject | dict[str, str] | — | Server-side JWT claim injection. Keys are SQL param names; values are "jwt:<claim>". Injected params are never exposed to clients. See Server-Side Injection. |
requires_role | str | — | Required role |
requires_scope | str | — | Required scope |
invalidates_views | list[str] | — | View names to purge from response cache after the mutation runs (e.g. ["v_post", "v_comment"]). |
invalidates_fact_tables | list[str] | — | Fact table names to invalidate after the mutation (e.g. ["tf_sales"]). |
cascade | bool | None | Expose GraphQL Cascade data in the mutation response. When True, the cascade field from mutation_response is included in the GraphQL response and fed into the cache invalidation pipeline. When None (default), falls back to the [cascade].enabled TOML setting. |
rest_path | str | None | None | REST endpoint path override (e.g. "/users"). When absent, REST auto-derives endpoints from mutation names. |
rest_method | str | None | "POST" | HTTP method override for the REST endpoint. Defaults to "POST" for mutations. |
@fraiseql.mutation( sql_source="fn_delete_user", operation="DELETE", requires_scope="admin:delete_user")def delete_user(id: ID) -> bool: """Delete a user. Requires admin scope.""" pass@Mutation({ sqlSource: 'fn_delete_user', operation: 'DELETE', requiresScope: 'admin:delete_user',})function deleteUser(id: ID): Promise<boolean> { return Promise.resolve(true);}Raise errors from within @before_mutation hooks or @after_mutation hooks to abort or signal failures:
# Raise errors from PostgreSQL functions using RAISE EXCEPTION —# not from Python hooks. The Python layer is schema authoring only.## In your PostgreSQL function:# IF user_is_system THEN# RAISE EXCEPTION 'Cannot delete system users'# USING ERRCODE = 'P0001', HINT = 'FORBIDDEN';# END IF;import { beforeMutation, FraiseQLError } from 'fraiseql';
beforeMutation('deleteUser', async (args, context) => { const user = await getUser(args.id); if (!user) { throw new FraiseQLError('NOT_FOUND', `User ${args.id} does not exist`); } if (user.isSystemUser) { throw new FraiseQLError('FORBIDDEN', 'Cannot delete system users'); }});FraiseQLError produces a structured GraphQL error with an extensions.code field:
{ "errors": [ { "message": "Cannot delete system users", "extensions": { "code": "FORBIDDEN" } } ]}Defines a GraphQL subscription.
@fraiseql.subscription(entity_type="User", topic="user_created")def user_created() -> User: """Subscribe to new users.""" passimport { Subscription } from 'fraiseql';
@Subscription({ entityType: 'User', topic: 'user_created' })function userCreated(): AsyncIterator<User> { return {} as AsyncIterator<User>;}Generated GraphQL SDL:
type Subscription { userCreated: User!}| Option | Type | Description |
|---|---|---|
entity_type | str | Entity to watch |
topic | str | NATS topic |
operation | str | "INSERT", "UPDATE", "DELETE" |
@fraiseql.subscription( entity_type="Order", operation="UPDATE", topic="order_updated")def order_shipped(customer_id: ID | None = None) -> Order: """Subscribe to order updates.""" pass@Subscription({ entityType: 'Order', operation: 'UPDATE', topic: 'order_updated',})function orderShipped(customerId?: ID): AsyncIterator<Order> { return {} as AsyncIterator<Order>;}Defines a GraphQL input type.
@fraiseql.inputclass CreateUserInput: """Input for creating a user.""" email: str name: str bio: str | None = None role: str = "user"Generated GraphQL SDL:
input CreateUserInput { """Input for creating a user.""" email: String! name: String! bio: String role: String!}Input validation is enforced in PostgreSQL functions using RAISE EXCEPTION, not via Python-level decorators. See Validation Rules for the TOML-based validation configuration that FraiseQL applies before SQL execution.
Defines a GraphQL enum.
from enum import Enum
@fraiseql.enumclass OrderStatus(Enum): """Status of an order.""" PENDING = "pending" CONFIRMED = "confirmed" SHIPPED = "shipped" DELIVERED = "delivered" CANCELLED = "cancelled"Usage:
@fraiseql.typeclass Order: id: ID status: OrderStatusDefines a GraphQL interface.
@fraiseql.interfaceclass Node: """An object with an ID.""" id: ID
@fraiseql.interfaceclass Timestamped: """An object with timestamps.""" created_at: DateTime updated_at: DateTimeImplement interfaces:
@fraiseql.type(implements=["Node", "Timestamped"])class User: id: ID name: str created_at: DateTime updated_at: DateTimeDefines a GraphQL union type.
@fraiseql.typeclass User: id: ID name: str
@fraiseql.typeclass Post: id: ID title: str
@fraiseql.union(members=[User, Post])class SearchResult: """Result from a search query.""" passUsage:
@fraiseql.querydef search(query: str) -> list[SearchResult]: return fraiseql.config(sql_source="fn_search")Defines a custom scalar type. The class must inherit from CustomScalar and implement serialize, parse_value, and parse_literal.
from fraiseql import CustomScalar, scalar
@scalarclass TaxId(CustomScalar): """Tax ID with validation.""" name = "TaxId"
def serialize(self, value: str) -> str: return value.upper().replace("-", "")
def parse_value(self, value: str) -> str: cleaned = value.upper().replace("-", "") if len(cleaned) != 9: raise ValueError("Tax ID must be 9 characters") return cleaned
def parse_literal(self, ast) -> str: return self.parse_value(ast.value)Use @fraiseql.error to annotate error types returned by mutations. Scalar fields (str, int, float, datetime, UUID, etc.) are populated from the metadata JSONB column of the mutation_response row returned by your SQL function. Both camelCase and snake_case metadata keys are resolved automatically.
@fraiseql.errorclass InsufficientFundsError: code: str required_amount: float available_amount: float
@fraiseql.mutation(sql_source="fn_transfer_funds")def transfer_funds( from_id: str, to_id: str, amount: float) -> Transfer | InsufficientFundsError: ...This generates a GraphQL union return type — clients handle domain errors as typed data rather than error extensions. The SQL function must return a mutation_response row; when status begins with "failed:" or is "error", the metadata JSONB is used to populate the error type’s fields.
Field-level configuration for use with Annotated.
fraiseql.field( requires_scope="scope", # Required OAuth scope on_deny="reject", # "reject" (default) or "mask" deprecated="message", # Deprecation reason description="text", # Field description)| Parameter | Type | Description |
|---|---|---|
requires_scope | str | OAuth scope required to read this field |
on_deny | str | "reject" raises an error; "mask" returns null |
deprecated | str | Marks the field deprecated with a reason message |
description | str | Field description exposed in introspection |
@fraiseql.typeclass User: bio: str | None # Nullable field@fraiseql.typeclass User: roles: list[str] # Non-null list, non-null items tags: list[str] | None # Nullable list@fraiseql.typeclass User: posts: list['Post'] # Forward reference manager: 'User | None' # Self-reference| Decorator | Purpose | Key Options |
|---|---|---|
@type | Define object type | implements, relay, requires_role |
@input | Define input type | — |
@query | Define query | sql_source, auto_params, cache_ttl_seconds, rest_path |
@mutation | Define mutation | sql_source, operation, requires_scope, cascade, rest_path |
@subscription | Define subscription | entity_type, topic, operation |
@enum | Define enum | — |
@interface | Define interface | — |
@union | Define union | members |
@scalar | Define custom scalar | — |
@error | Define error type | — |
@fraiseql.query(auto_params={"limit": True, "offset": True, "where": True, "order_by": True})def users() -> list[User]: return fraiseql.config(sql_source="v_user")# With Annotatedfrom typing import Annotated
@fraiseql.typeclass User: id: ID email: Annotated[str, fraiseql.field( requires_scope="user:read_email", # Require scope on_deny="mask", # Return null instead of error deprecated="Use contactEmail instead", # Deprecation description="Primary contact email", # Description )]Define a simple type with decorators:
import fraiseql
@fraiseql.typeclass Product: id: str name: str price: floatAdd a query decorator:
@fraiseql.querydef product(id: str) -> Product: return fraiseql.config(sql_source="v_product")Compile the schema:
fraiseql compileExpected output:
✓ Schema validated (1 types, 1 queries)✓ Compiled to schema.compiled.jsonIntrospect the generated schema:
curl -X POST http://localhost:8080/graphql \ -H "Content-Type: application/json" \ -d '{"query": "{ __type(name: \"Product\") { fields { name } } }"}' | jqExpected response:
{ "data": { "__type": { "fields": [ {"name": "id"}, {"name": "name"}, {"name": "price"} ] } }}Test the query:
curl -X POST http://localhost:8080/graphql \ -H "Content-Type: application/json" \ -d '{"query": "{ product(id: \"123\") { id name price } }"}'Test with authorization:
@fraiseql.mutation( sql_source="fn_create_product", requires_scope="admin:products")def create_product(name: str, price: float) -> Product: passTest without scope (should fail):
curl -X POST http://localhost:8080/graphql \ -H "Authorization: Bearer $USER_TOKEN" \ -d '{"query": "mutation { createProduct(name: \"Widget\", price: 9.99) { id } }"}'Expected error:
{ "errors": [{ "message": "Forbidden: missing scope admin:products" }]}@fraiseql.typeclass User: pass# Error: module 'fraiseql' has no attribute 'type'Solution: Use correct import:
import fraiseql
@fraiseql.type # Correctclass User: pass$ fraiseql compileWarning: Type 'User' has no fieldsCheck:
@fraiseql.typeclass User: posts: list[Post] # Error: Post not definedSolution: Use string literal for forward reference:
@fraiseql.typeclass User: posts: list['Post'] # Forward reference as string@fraiseql.mutation(sql_source="fn_create_user")def create_user(name: str) -> User: passCheck:
SELECT * FROM pg_proc WHERE proname = 'fn_create_user'Type System
Type System — Type definitions
Scalars
Scalars — Built-in scalars
GraphQL API
GraphQL API — Generated API