2.4: Type System¶
Audience: Schema designers, backend developers, API architects Prerequisite: 2.2 Core Concepts, 2.3 Database-Centric Architecture Reading Time: 15-20 minutes
Overview¶
FraiseQL's type system is the bridge between your Python type hints and your GraphQL API. You declare types with @fraiseql.type and annotate fields with ordinary Python type hints; FraiseQL reads those hints when the schema is built and maps each one to a GraphQL type.
Key Insight: Your GraphQL schema is generated from your Python type definitions at app startup. There is no separate compile or codegen step — the schema is assembled in memory when the application boots and is served over FastAPI.
FraiseQL is PostgreSQL-only. Object types are backed by read views (v_/tv_) that expose a data JSONB column, and the shape of that JSONB matches the GraphQL type. See Database-Centric Architecture for how views and the data column work.
Type System Flow¶
Python type hints
(@fraiseql.type field annotations: int, str, bool, datetime, UUID, ...)
↓
Schema build at app startup
(FraiseQL reads the annotations in memory)
↓
GraphQL types
(Int, String, Boolean, DateTime, ID, ...)
↓
API contract
(served over FastAPI / graphql-core)
Backing the runtime: a PostgreSQL read view returns a data JSONB document whose keys correspond to the GraphQL fields. FraiseQL shapes that JSONB to the fields the client requested.
Built-In Scalar Types¶
The Mapping¶
FraiseQL maps common Python types to GraphQL scalars, and provides domain scalars you can use explicitly. Import the scalars you need:
from fraiseql.types import ID, Date, DateTime, Time, EmailAddress, JSON, LTree
| Python type | GraphQL type | PostgreSQL (data JSONB value) |
Example |
|---|---|---|---|
int |
Int |
JSON number | 123 |
float |
Float |
JSON number | 3.14 |
str |
String |
JSON string | "hello" |
bool |
Boolean |
JSON boolean | true |
ID |
ID |
JSON string (UUID) | "550e8400-e29b-41d4-a716-446655440000" |
UUID |
UUID |
JSON string | "550e8400-..." |
Date |
Date |
ISO date string | "2026-01-29" |
DateTime |
DateTime |
ISO timestamp string | "2026-01-29T14:30:00Z" |
Time |
Time |
ISO time string | "14:30:00" |
EmailAddress |
EmailAddress |
JSON string | "a@example.com" |
JSON |
JSON |
nested JSON | {"key": "value"} |
LTree |
LTree |
JSON string (label path) | "top.science.maths" |
FraiseQL ships many more domain scalars (currency codes, IP addresses, vectors, and more). See the full list in Custom Scalar Types — only use scalars documented there.
Defining a Type¶
A @fraiseql.type class declares the GraphQL object type and the read view that backs it. jsonb_column defaults to "data".
import fraiseql
from fraiseql.types import ID, DateTime, EmailAddress
@fraiseql.type(sql_source="v_user", jsonb_column="data")
class User:
id: ID
email: EmailAddress
first_name: str | None # nullable field
last_name: str | None # nullable field
is_active: bool
created_at: DateTime
metadata: dict | None # nullable JSON object
Generated GraphQL type:
type User {
id: ID!
email: EmailAddress!
firstName: String
lastName: String
isActive: Boolean!
createdAt: DateTime!
metadata: JSON
}
The id field is the public UUID exposed as ID. Internal keys (pk_*, fk_*) are never part of the data JSONB and never appear in GraphQL. See Database-Centric Architecture for the trinity identifier pattern (pk_ internal, id public UUID, optional identifier slug).
Nullable vs Non-Nullable Types¶
Nullability comes directly from your Python type hints.
Rule: X | None → nullable; bare X → non-nullable.
@fraiseql.type(sql_source="v_order")
class Order:
id: ID # → ID! (non-nullable)
total: float # → Float! (non-nullable)
note: str | None # → String (nullable)
status: str # → String! (non-nullable)
Result in GraphQL:
type Order {
id: ID!
total: Float!
note: String
status: String!
}
A non-nullable field must always be present in the response; a nullable field may be null.
query {
user {
email # always present (non-null in schema)
phone # included if set, null if not
}
}
Make sure your read view's data JSONB always provides a value for non-nullable fields, otherwise resolution will fail at runtime.
Composite Types: Objects and Relationships¶
Object Types¶
An object type is a composite type with multiple fields:
import fraiseql
from fraiseql.types import ID, DateTime
@fraiseql.type(sql_source="v_order")
class Order:
id: ID
total: float
status: str
created_at: DateTime
The backing read view builds the data JSONB:
CREATE VIEW v_order AS
SELECT
o.id, -- public UUID, exposed as ID
jsonb_build_object(
'id', o.id,
'total', o.total,
'status', o.status,
'created_at', o.created_at
) AS data
FROM tb_order o;
Relationships: One-to-Many¶
Relationships are expressed as nested types in Python, and the backing read view composes the nested objects into the data JSONB.
import fraiseql
from fraiseql.types import ID
@fraiseql.type(sql_source="v_order")
class Order:
id: ID
total: float
@fraiseql.type(sql_source="v_user")
class User:
id: ID
email: str
orders: list[Order] # one-to-many: a user has many orders
Generated GraphQL types:
type User {
id: ID!
email: String!
orders: [Order!]!
}
type Order {
id: ID!
total: Float!
}
The v_user view builds the nested orders inside the user's data JSONB (for example with jsonb_agg(...) over the related rows), so the relationship is pre-composed in PostgreSQL:
CREATE VIEW v_user AS
SELECT
u.id,
jsonb_build_object(
'id', u.id,
'email', u.email,
'orders', COALESCE(
(SELECT jsonb_agg(jsonb_build_object('id', o.id, 'total', o.total))
FROM tb_order o
WHERE o.fk_user = u.pk_user),
'[]'::jsonb
)
) AS data
FROM tb_user u;
Query example:
query GetUserWithOrders($id: ID!) {
user(id: $id) {
id
email
orders {
id
total
}
}
}
Relationships: Many-to-Many¶
A junction table (tb_student_courses) links the two sides; each side's read view composes the related objects into its data JSONB.
import fraiseql
from fraiseql.types import ID
@fraiseql.type(sql_source="v_course")
class Course:
id: ID
title: str
@fraiseql.type(sql_source="v_student")
class Student:
id: ID
name: str
courses: list[Course] # many-to-many via junction table
Generated GraphQL types:
type Student {
id: ID!
name: String!
courses: [Course!]!
}
type Course {
id: ID!
title: String!
}
List Types¶
Lists come from Python list[...] annotations. Optionality of the list itself follows the same X | None rule.
@fraiseql.type(sql_source="v_user")
class User:
tags: list[str] # → [String!]! (list and items non-null)
notes: list[str] | None # → [String!] (list nullable, items non-null)
GraphQL list modifiers:
[String] # list can be null, items can be null
[String!] # list can be null, items non-null
[String]! # list non-null, items can be null
[String!]! # list non-null, items non-null
A non-empty, non-null list:
type User {
tags: [String!]!
}
# Valid:
{ "tags": ["vip", "premium"] }
# Invalid:
{ "tags": null } # list is non-null
{ "tags": ["vip", null] } # items must be non-null
One-to-many relationships are expressed as list[RelatedType] and become [RelatedType!]!, as shown above.
Input Types¶
Use @fraiseql.input for arguments to mutations and queries. Inputs follow the same Python-type-hint mapping.
import fraiseql
@fraiseql.input
class CreateUserInput:
name: str
email: str
age: int | None = None # optional input field with default
Generated GraphQL input:
input CreateUserInput {
name: String!
email: String!
age: Int
}
A mutation consumes the input and delegates the write to a PostgreSQL function:
import fraiseql
@fraiseql.mutation
async def create_user(info, input: CreateUserInput) -> CreateUserSuccess | CreateUserError:
db = info.context["db"]
result = await db.execute_function(
"fn_create_user",
{"name": input.name, "email": input.email},
)
if not result.get("success"):
return CreateUserError(message=result.get("message", "failed"))
return CreateUserSuccess(user=User(**result["user"]))
See Error Handling & Validation for the success/error result pattern.
Enum Types¶
Use @fraiseql.enum on a Python enum.Enum to expose a GraphQL enum.
import fraiseql
from enum import Enum
@fraiseql.enum
class OrderStatus(Enum):
PENDING = "pending"
CONFIRMED = "confirmed"
SHIPPED = "shipped"
DELIVERED = "delivered"
@fraiseql.type(sql_source="v_order")
class Order:
id: ID
status: OrderStatus
Generated GraphQL:
enum OrderStatus {
PENDING
CONFIRMED
SHIPPED
DELIVERED
}
type Order {
id: ID!
status: OrderStatus!
}
Enums are a good fit for constrained values stored as text (or a PostgreSQL ENUM) in your tables.
Interface Types¶
Use @fraiseql.interface to declare a shared field contract that multiple object types implement.
import fraiseql
from fraiseql.types import ID, DateTime
@fraiseql.interface
class Node:
id: ID
created_at: DateTime
@fraiseql.type(sql_source="v_user")
class User(Node):
email: str
@fraiseql.type(sql_source="v_order")
class Order(Node):
total: float
Generated GraphQL:
interface Node {
id: ID!
createdAt: DateTime!
}
type User implements Node {
id: ID!
createdAt: DateTime!
email: String!
}
type Order implements Node {
id: ID!
createdAt: DateTime!
total: Float!
}
Union Types¶
A union is expressed with the A | B syntax — most commonly as a mutation return type combining a success and an error type.
import fraiseql
@fraiseql.success
class CreateUserSuccess:
user: User
@fraiseql.error
class CreateUserError:
message: str
code: str = "VALIDATION_ERROR"
@fraiseql.mutation
async def create_user(info, input: CreateUserInput) -> CreateUserSuccess | CreateUserError:
...
Generated GraphQL:
union CreateUserResult = CreateUserSuccess | CreateUserError
Clients use inline fragments to read the result:
mutation {
createUser(input: { name: "Bob", email: "bob@example.com" }) {
... on CreateUserSuccess { user { id email } }
... on CreateUserError { message code }
}
}
Type Safety in Action¶
At Schema Build (App Startup)¶
When the application boots, FraiseQL reads your Python type hints and assembles the GraphQL schema in memory. This is where field-to-type mapping and nullability are resolved.
@fraiseql.type(sql_source="v_user")
class User:
id: ID # → ID! (non-nullable)
email: str # → String! (non-nullable)
phone: str | None # → String (nullable)
✅ id: annotated ID → GraphQL ID! (non-null)
✅ email: annotated str → GraphQL String! (non-null)
✅ phone: annotated str | None → GraphQL String (nullable)
✅ schema assembled in memory, served over FastAPI
At Runtime (Query Execution)¶
graphql-core validates incoming variables and arguments against the schema, and FraiseQL shapes the read view's data JSONB to the requested fields.
query GetUser($id: ID!) {
user(id: $id) {
id
email
}
}
# Variables: { "id": 123 } # wrong type
❌ Variable $id: expected ID, got Int
Error: "Variable '$id' got invalid value 123; ID cannot represent a non-string value"
Worked Example: User with Orders¶
Python types:
import fraiseql
from fraiseql.types import ID, DateTime
@fraiseql.type(sql_source="v_order")
class Order:
id: ID
total: float
created_at: DateTime
@fraiseql.type(sql_source="v_user")
class User:
id: ID
email: str
first_name: str | None
last_name: str | None
created_at: DateTime
orders: list[Order]
Generated GraphQL types:
type User {
id: ID!
email: String!
firstName: String
lastName: String
createdAt: DateTime!
orders: [Order!]!
}
type Order {
id: ID!
total: Float!
createdAt: DateTime!
}
The v_user read view builds the user's data JSONB (including the nested orders array), and the v_order view builds each order's data. No foreign keys are exposed; the relationship is composed in the view.
Type System Best Practices¶
1. Be explicit about nullability¶
# Be explicit with the | None hint
@fraiseql.type(sql_source="v_user")
class User:
email: str # → String! (always present)
phone: str | None # → String (may be null)
2. Model relationships, not foreign keys¶
Expose related objects (user: User, orders: list[Order]) rather than raw fk_* columns. The trinity pattern keeps pk_/fk_ internal and out of the data JSONB.
3. Use enums for constrained values¶
@fraiseql.enum
class OrderStatus(Enum):
PENDING = "pending"
CONFIRMED = "confirmed"
SHIPPED = "shipped"
DELIVERED = "delivered"
4. Prefer domain scalars where they add validation¶
Use EmailAddress over a bare str for emails, Date/DateTime over strings for dates, and so on. See Custom Scalar Types.
Related Topics¶
- 2.2 Core Concepts — understanding types and terminology
- 2.3 Database-Centric Architecture — read views,
dataJSONB, the trinity identifier pattern - 2.5 Error Handling & Validation — success/error result types and validation
- Custom Scalar Types — the full scalar catalogue
- Where Operators — filtering query results
- Concepts Glossary — terminology reference
- Quickstart — define your first types end-to-end
Summary¶
FraiseQL's type system maps your Python type hints to GraphQL at app startup:
Built-in scalar mappings:
- Numbers:
int→Int,float→Float - Text:
str→String - Booleans:
bool→Boolean - Identifiers / dates:
ID,UUID,Date,DateTime,Time - Domain scalars:
EmailAddress,JSON,LTree, and more (see the scalar reference)
Key principles:
- Python type hints drive the schema — types are read in memory when the app boots; there is no compile step.
X | Nonedrives nullability — bareXis non-null,X | Noneis nullable.- Relationships are nested types —
list[Related]becomes[Related!]!, composed in the backing read view. - PostgreSQL-backed — object types map to
v_/tv_views exposing adataJSONB;pk_/fk_stay internal. - Self-documenting — the schema clearly shows what is required vs optional.