Skip to content

Type System

FraiseQL’s type system bridges Python types, GraphQL schema, and PostgreSQL. This guide explains how types flow through the system.

Each layer has its own type representation:

PythonGraphQLPostgreSQLJSON
strString!TEXT"string"
intInt!INTEGER123
floatFloat!DOUBLE PRECISION1.23
boolBoolean!BOOLEANtrue
str | NoneStringTEXT"string" or null
list[str][String!]!TEXT[]["a", "b"]
TypeScriptGraphQLPostgreSQLMySQLSQLite
stringStringTEXTVARCHAR(255)TEXT
number (int)IntINTEGERINTINTEGER
number (float)FloatFLOAT8DOUBLEREAL
booleanBooleanBOOLEANTINYINT(1)INTEGER
DateDateTimeTIMESTAMPTZDATETIMETEXT
string[][String!]!TEXT[]JSONTEXT (JSON)

When using the gRPC transport, FraiseQL maps Python types to Protobuf types for the generated .proto schema:

PythonGraphQLProtobufWire Type
strString!stringLength-delimited
intInt!int32Varint
floatFloat!double64-bit
boolBoolean!boolVarint
str | NoneStringoptional stringLength-delimited
list[str][String!]!repeated stringLength-delimited
IDID!stringLength-delimited (UUID as string)
DateTimeDateTime!google.protobuf.TimestampMessage
DecimalDecimal!stringLength-delimited (decimal as string)

For a complete reference of scalar-to-Protobuf mappings, see Scalars.

import fraiseql
from fraiseql.scalars import ID, DateTime, Decimal
@fraiseql.type
class User:
"""A user in the system."""
id: ID # UUID → ID! → UUID → "uuid-string"
email: str # str → String! → TEXT → "string"
name: str # str → String! → TEXT → "string"
age: int # int → Int! → INTEGER → 123
balance: Decimal # Decimal → Decimal! → NUMERIC → "123.45"
is_active: bool # bool → Boolean! → BOOLEAN → true
created_at: DateTime # DateTime → DateTime! → TIMESTAMPTZ → "2024-01-15T..."

Use union with None for nullable fields:

@fraiseql.type
class User:
bio: str | None # String (nullable)
avatar_url: str | None # String (nullable)
deleted_at: DateTime | None # DateTime (nullable)
@fraiseql.type
class User:
roles: list[str] # [String!]! - non-null list, non-null items
tags: list[str] | None # [String!] - nullable list, non-null items
scores: list[int] # [Int!]!
@fraiseql.type
class Post:
id: ID
title: str
author: User # Nested User object

GraphQL:

type Post {
id: ID!
title: String!
author: User!
}

Use string literals for forward references:

@fraiseql.type
class User:
id: ID
name: str
posts: list['Post'] # Forward reference to Post
@fraiseql.type
class Post:
id: ID
title: str
author: 'User' # Forward reference to User
@fraiseql.type
class Comment:
id: ID
content: str
parent: 'Comment | None' # Self-reference
replies: list['Comment'] # Self-reference list
@fraiseql.type
class Post:
id: ID
featured_image: 'Image | None' # Optional relationship
from fraiseql.scalars import (
ID, # UUID
DateTime, # Timestamp with timezone
Date, # Date only
Time, # Time only
Decimal, # Arbitrary precision
Json, # JSONB
Vector, # pgvector
)

Custom scalars must subclass CustomScalar and implement serialize, parse_value, and parse_literal as instance methods:

from fraiseql.scalars import CustomScalar
import fraiseql
import re
@fraiseql.scalar
class Email(CustomScalar):
"""Email address with validation."""
name = "Email"
def serialize(self, value: str) -> str:
return str(value).lower()
def parse_value(self, value: str) -> str:
if not re.match(r'^[^@]+@[^@]+\.[^@]+$', str(value)):
raise ValueError("Invalid email")
return str(value).lower()
def parse_literal(self, ast) -> str:
if hasattr(ast, 'value'):
return self.parse_value(ast.value)
raise ValueError("Invalid email literal")

Usage:

@fraiseql.type
class User:
email: Email # Custom scalar
from enum import Enum
@fraiseql.enum
class OrderStatus(Enum):
"""Order status values."""
PENDING = "pending"
CONFIRMED = "confirmed"
SHIPPED = "shipped"
DELIVERED = "delivered"
CANCELLED = "cancelled"
@fraiseql.type
class Order:
id: ID
status: OrderStatus # Enum type

GraphQL:

enum OrderStatus {
PENDING
CONFIRMED
SHIPPED
DELIVERED
CANCELLED
}
type Order {
id: ID!
status: OrderStatus!
}

Define shared fields across types:

@fraiseql.interface
class Node:
"""An object with a globally unique ID."""
id: ID
@fraiseql.interface
class Timestamped:
"""An object with timestamps."""
created_at: DateTime
updated_at: DateTime
@fraiseql.type(implements=["Node", "Timestamped"])
class User:
id: ID
name: str
created_at: DateTime
updated_at: DateTime

GraphQL:

interface Node {
id: ID!
}
interface Timestamped {
createdAt: DateTime!
updatedAt: DateTime!
}
type User implements Node & Timestamped {
id: ID!
name: String!
createdAt: DateTime!
updatedAt: DateTime!
}

Represent multiple possible types:

@fraiseql.type
class User:
id: ID
name: str
@fraiseql.type
class Organization:
id: ID
name: str
@fraiseql.union(members=[User, Organization])
class Actor:
"""An entity that can perform actions."""
pass

GraphQL:

union Actor = User | Organization

Query with type resolution:

query {
actor(id: "...") {
... on User {
name
email
}
... on Organization {
name
memberCount
}
}
}

Input types for mutations:

@fraiseql.input
class CreateUserInput:
"""Input for creating a user."""
email: str
name: str
bio: str | None = None
@fraiseql.input
class UpdateUserInput:
"""Input for updating a user."""
name: str | None = None
bio: str | None = None

GraphQL:

input CreateUserInput {
email: String!
name: String!
bio: String
}
input UpdateUserInput {
name: String
bio: String
}

FraiseQL infers types from Python annotations:

CREATE VIEW v_user AS
SELECT
u.id, -- UUID
jsonb_build_object(
'id', u.id::text, -- String in JSONB
'name', u.name, -- String
'age', u.age, -- Integer
'balance', u.balance::text -- Decimal as string
) AS data
FROM tb_user u;
CREATE FUNCTION fn_create_user(
user_email TEXT,
user_name TEXT
) RETURNS mutation_response AS $$

FraiseQL infers:

  • Parameters: email: str, name: str
  • Return: entity resolved from the entity JSONB field in mutation_response
PythonGraphQLNotes
strString!Non-null
str | NoneStringNullable
list[str][String!]!Non-null list and items
list[str] | None[String!]Nullable list, non-null items
list[str | None][String]!Non-null list, nullable items
PostgreSQLPython
NOT NULLRequired field
Nullable... | None
DEFAULTDefault value

Input validation is handled in your PostgreSQL fn_* functions using RAISE EXCEPTION or by using custom scalar types. There is no fraiseql.validate() function in the Python package.

For field-level validation via custom scalars, define a CustomScalar subclass (see the Custom Scalars section above) and use it as the field’s type annotation:

@fraiseql.input
class CreateUserInput:
email: Email # Custom scalar validates at parse time
name: str

Type coercion at GraphQL boundaries (string "123" → integer 123) is handled by the Rust runtime, not Python code. The Python package is compile-time only — it produces schema.json and has no runtime behavior.

Computed fields are calculated in the SQL view. Declare them as regular annotated fields in the Python type — the computation happens entirely in PostgreSQL:

@fraiseql.type
class User:
first_name: str
last_name: str
full_name: str # Computed in the SQL view

SQL implementation — compute in the view, include u.id as required:

CREATE VIEW v_user AS
SELECT
u.id,
jsonb_build_object(
'first_name', u.first_name,
'last_name', u.last_name,
'full_name', u.first_name || ' ' || u.last_name -- Computed
) AS data
FROM tb_user u;

There are no @fraiseql.computed decorator or fraiseql.field(computed=True) parameter. Computed fields are a SQL view concern, not a Python annotation concern.

Control field exposure:

from typing import Annotated
from fraiseql.scalars import ID, Decimal
import fraiseql
@fraiseql.type
class User:
id: ID
email: str
# To exclude password_hash: simply don't declare it here.
# Ensure v_user's jsonb_build_object() does not include password_hash.
salary: Annotated[Decimal, fraiseql.field(
requires_scope="hr:read" # Require scope
)]

There is no fraiseql.field(exclude=True) parameter. To exclude a field from GraphQL, omit it from the Python type class and ensure the SQL view’s jsonb_build_object() does not include it.

# Good: Specific types
id: ID
email: str
price: Decimal
created_at: DateTime
# Avoid: Generic types
id: str # Could be anything
price: float # Precision issues
created_at: str # Format unclear
# PostgreSQL: DECIMAL(12,2)
# Python: Decimal (not float)
price: Decimal
# PostgreSQL: TIMESTAMPTZ
# Python: DateTime (not str)
created_at: DateTime
@fraiseql.type
class Order:
"""
Represents a customer order.
Contains order details, line items, and fulfillment status.
"""
id: ID
"""Unique order identifier."""
total: Decimal
"""Order total in USD."""