Skip to content

Custom Scalar Types

Custom scalar types let you create domain-specific types that carry validation logic into your GraphQL schema. FraiseQL’s Python SDK is compile-time only — your @scalar class is exported to schema.json and the Rust runtime handles the schema from there. This guide shows you how to design and implement custom scalars correctly.

FraiseQL ships with a large library of built-in semantic scalars (Email, PhoneNumber, URL, UUID, DateTime, and many more). Use those when they fit.

Write a CustomScalar subclass when you need:

  • Domain-specific validation logic that differs from the built-in types
  • Business-rule enforcement at the schema boundary (e.g., minimum age, range constraints)
  • Custom serialization of values going to and from clients
ScenarioSolution
Standard email addressUse built-in Email scalar from fraiseql.scalars
Email with custom domain allowlistCustomScalar subclass with domain check in parse_value
Standard UUIDUse built-in UUID scalar from fraiseql.scalars
Custom range-bounded integerCustomScalar subclass
ISO 8601 datetimeUse built-in DateTime scalar

All custom scalars must subclass CustomScalar and implement three instance methods:

  • serialize(self, value) — convert an internal value to its GraphQL wire format
  • parse_value(self, value) — validate and convert a client-supplied variable value
  • parse_literal(self, ast) — validate and convert a hardcoded literal in a GraphQL query

The class must also declare a name class attribute — this becomes the scalar name in the compiled schema.

import fraiseql
from fraiseql import CustomScalar, scalar
@scalar
class EmailAddress(CustomScalar):
"""Email address with basic RFC 5322 format validation."""
name = "EmailAddress"
def serialize(self, value):
result = str(value).lower()
if "@" not in result:
raise ValueError(f"Cannot serialize invalid email: {value!r}")
return result
def parse_value(self, value):
if not isinstance(value, str):
raise ValueError("Email must be a string")
if "@" not in value or "." not in value.split("@")[-1]:
raise ValueError(f"Invalid email format: {value!r}")
return value.lower()
def parse_literal(self, ast):
if hasattr(ast, "value"):
return self.parse_value(ast.value)
raise ValueError("Expected a string literal for EmailAddress")
"""Email address with basic RFC 5322 format validation."""
scalar EmailAddress

Once registered, use the class as a type annotation inside @fraiseql.type:

import fraiseql
from fraiseql.scalars import ID
@fraiseql.type
class User:
id: ID
email: EmailAddress # registered custom scalar
name: str

The compiled schema references EmailAddress as a custom scalar, and the Rust runtime enforces the validation contract at query/mutation boundaries.

from fraiseql import CustomScalar, scalar
@scalar
class PositiveInt(CustomScalar):
"""An integer greater than zero."""
name = "PositiveInt"
def serialize(self, value):
n = int(value)
if n <= 0:
raise ValueError(f"PositiveInt must be > 0, got {n}")
return n
def parse_value(self, value):
if not isinstance(value, int) or isinstance(value, bool):
raise ValueError("PositiveInt must be an integer")
if value <= 0:
raise ValueError(f"PositiveInt must be > 0, got {value}")
return value
def parse_literal(self, ast):
if hasattr(ast, "value"):
return self.parse_value(int(ast.value))
raise ValueError("Expected an integer literal for PositiveInt")
from fraiseql import CustomScalar, scalar
@scalar
class USDPrice(CustomScalar):
"""Price in USD. Must be between $0.01 and $999,999.99."""
name = "USDPrice"
def serialize(self, value):
amount = round(float(value), 2)
if not (0.01 <= amount <= 999_999.99):
raise ValueError(f"USDPrice out of range: {amount}")
return amount
def parse_value(self, value):
try:
amount = round(float(value), 2)
except (TypeError, ValueError) as e:
raise ValueError(f"USDPrice must be a number, got {value!r}") from e
if not (0.01 <= amount <= 999_999.99):
raise ValueError(f"USDPrice must be between $0.01 and $999,999.99")
return amount
def parse_literal(self, ast):
if hasattr(ast, "value"):
return self.parse_value(ast.value)
raise ValueError("Expected a numeric literal for USDPrice")
import re
from fraiseql import CustomScalar, scalar
_E164_RE = re.compile(r"^\+[1-9]\d{1,14}$")
@scalar
class E164Phone(CustomScalar):
"""Phone number in E.164 format (e.g. +12025551234)."""
name = "E164Phone"
def serialize(self, value):
s = str(value)
if not _E164_RE.match(s):
raise ValueError(f"Cannot serialize invalid E.164 phone: {s!r}")
return s
def parse_value(self, value):
if not isinstance(value, str):
raise ValueError("E164Phone must be a string")
if not _E164_RE.match(value):
raise ValueError(
f"E164Phone must match +[country][number] format, got {value!r}"
)
return value
def parse_literal(self, ast):
if hasattr(ast, "value"):
return self.parse_value(ast.value)
raise ValueError("Expected a string literal for E164Phone")

For common domain types, FraiseQL provides pre-defined scalars in fraiseql.scalars. These are NewType aliases — import them directly and use as type annotations without writing a CustomScalar subclass.

import fraiseql
from fraiseql.scalars import ID, Email, PhoneNumber, URL, DateTime, UUID, Decimal
@fraiseql.type
class Contact:
id: ID
email: Email
phone: PhoneNumber | None
website: URL | None
created_at: DateTime

The available built-in scalars include: ID, UUID, DateTime, Date, Time, Json, Decimal, Vector, Email, PhoneNumber, URL, Slug, Markdown, HTML, IPAddress, CurrencyCode, Money, LTree, and many more. See the Semantic Scalars Reference for the full list.

After defining your types and scalars, export the schema for compilation:

import fraiseql
# ... define @fraiseql.type, @fraiseql.query, @fraiseql.scalar classes ...
fraiseql.export_schema("schema.json")

Then compile with the CLI:

Terminal window
fraiseql compile

The compiler validates all custom scalar references and emits schema.compiled.json for the Rust runtime.

Use fraiseql.validators.validate_custom_scalar to unit-test your scalar logic directly, without standing up a server.

import pytest
from fraiseql.validators import validate_custom_scalar, ScalarValidationError
from myapp.scalars import EmailAddress, USDPrice
class TestEmailAddressScalar:
def test_valid_email_parse_value(self):
result = validate_custom_scalar(EmailAddress, "User@Example.com")
assert result == "user@example.com"
def test_missing_at_sign_raises(self):
with pytest.raises(ScalarValidationError):
validate_custom_scalar(EmailAddress, "notanemail")
def test_serialize_lowercases(self):
result = validate_custom_scalar(EmailAddress, "Admin@Example.COM", context="serialize")
assert result == "admin@example.com"
class TestUSDPriceScalar:
def test_valid_price(self):
result = validate_custom_scalar(USDPrice, 9.99)
assert result == 9.99
def test_zero_price_raises(self):
with pytest.raises(ScalarValidationError):
validate_custom_scalar(USDPrice, 0.0)
def test_too_large_raises(self):
with pytest.raises(ScalarValidationError):
validate_custom_scalar(USDPrice, 1_000_000.00)

The validate_custom_scalar signature:

def validate_custom_scalar(
scalar_class: type[CustomScalar],
value: Any,
context: str = "parse_value", # "serialize" | "parse_value" | "parse_literal"
) -> Any:
...

ScalarValidationError carries the scalar name, context, and message for precise failure reporting.

Define each scalar in a dedicated module (e.g. myapp/scalars.py) and import that module before calling export_schema:

myapp/scalars.py
from fraiseql import CustomScalar, scalar
import re
@scalar
class EmailAddress(CustomScalar):
name = "EmailAddress"
# ... methods ...
@scalar
class USDPrice(CustomScalar):
name = "USDPrice"
# ... methods ...
myapp/schema.py
import fraiseql
import myapp.scalars # ensures all @scalar decorators run before export
from fraiseql.scalars import ID
from myapp.scalars import EmailAddress, USDPrice
@fraiseql.type
class Product:
id: ID
name: str
price: USDPrice
fraiseql.export_schema("schema.json")

Understanding the architecture prevents common mistakes:

Python source
@scalar class EmailAddress(CustomScalar)
fraiseql.export_schema("schema.json")
schema.json ← contains customScalars: { "EmailAddress": { ... } }
fraiseql compile
schema.compiled.json
Rust runtime (handles all GraphQL requests, enforces scalars)

There are no Python resolvers, no async def handlers, and no runtime FFI. The Python SDK is a schema authoring tool only.

Custom scalars map to Protobuf types for the gRPC transport. See the gRPC Transport page for the full GraphQL-to-Protobuf type mapping table.

Type System Guide

Type System — How to use scalars inside @fraiseql.type