Custom Scalar Types
Learn how to implement and configure custom scalars in your schema.
Elo is a domain-specific expression language designed for safe, human-readable validation logic. FraiseQL integrates Elo to enable powerful custom scalar validation at runtime.
Elo is an expression language created by Bernard Lambeau for expressing business rules and validation constraints. FraiseQL uses Elo to:
In FraiseQL, Elo expressions let you define custom scalar validation rules that are:
Comparison operators:
value < 100value <= 100value > 0value >= 0value == "admin"value != "pending"Logical operators:
value > 0 && value < 100 # ANDstatus == "active" || status == "pending" # OR!is_deleted # NOTString patterns:
matches(value, "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$")matches(value, "^[0-9]{3}-[0-9]{2}-[0-9]{4}$") # SSN formatElo provides built-in functions for common validation tasks:
String functions:
length(value) >= 3 # String lengthlength(value) <= 255Type checking:
Type checking is handled by the base type declared on the scalar — FraiseQL validates the type at the schema level before evaluating Elo expressions. The is_string(), is_number(), and is_date() functions are not implemented in the Elo evaluator.
Date functions:
today() # Current datenow() # Current timestampAge function:
age(value) >= 18 # Person must be at least 18 years oldage(value) < 65 # Person must be under 65Numeric functions:
value % 2 == 0 # Even numbervalue % 10 == value % 11 # Custom mathAccess different parts of your data:
# Direct value referencevalue >= 0
# Nested object access (in context of mutations)user.emailcustomer.billing_address.zip_codeNumbers:
value > 100value <= 1000.50Strings:
status == "active"country == "US"Booleans:
is_verified == trueis_deleted == falseDates:
created_at >= 2024-01-01birth_date < 2006-01-01 # Person must be 18+FraiseQL manages custom scalars through a thread-safe CustomTypeRegistry that stores CustomTypeDef definitions. When you define a custom scalar, FraiseQL compiles it into a schema definition that includes the Elo expression.
Custom scalars must subclass CustomScalar and implement serialize, parse_value, and parse_literal. Elo validation is attached to the scalar’s CustomTypeDef in the compiled schema — it is not a class attribute in Python.
from fraiseql.scalars import CustomScalarimport fraiseqlimport re
@fraiseql.scalarclass Email(CustomScalar): name = "Email"
def serialize(self, value: str) -> str: return str(value).lower()
def parse_value(self, value: str) -> str: if not re.match(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', str(value)): raise ValueError(f"Invalid email address: {value}") 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")The Elo expression for runtime validation is declared in the compiled schema — the Python class defines the serialization logic, while the Elo expression provides the validation rule.
When you define this scalar, FraiseQL creates a CustomTypeDef:
pub struct CustomTypeDef { pub name: "Email", pub description: Some("Valid RFC 5322 email address"), pub specified_by_url: None, pub validation_rules: [], // Built-in validators (if specified) pub elo_expression: Some('matches(value, ...)'), pub base_type: Some("String"),}After compilation, custom scalars appear in schema.compiled.json:
{ "custom_types": [ { "name": "Email", "description": "Valid RFC 5322 email address", "base_type": "String", "elo_expression": "matches(value, \"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$\") && length(value) <= 254", "specified_by_url": null }, { "name": "ISBN", "description": "International Standard Book Number", "base_type": "String", "elo_expression": "(length(value) == 10 && matches(value, \"^[0-9X]{10}$\")) || (length(value) == 13 && matches(value, \"^978[0-9]{10}$|^979[0-9]{10}$\"))" } ]}When a query or mutation provides a value of a custom scalar type, FraiseQL’s EloExpressionEvaluator interprets the Elo expression at runtime:
CustomTypeDef from registryelo_expression is present, interpret it with the input valueExample flow:
Input Value: "john@example.com" ↓CustomTypeRegistry.get("Email") ↓Execute elo_expression: matches(value, "^[a-zA-Z0-9._%+-]+@...") && length(value) <= 254 ↓Result: ValidCombine multiple conditions with logical operators in the Elo expression:
length(value) >= 8 &&matches(value, "[A-Z]") &&matches(value, "[a-z]") &&matches(value, "[0-9]") &&matches(value, "[!@#$%^&*]")This expression is evaluated left-to-right with short-circuit AND logic — if length(value) >= 8 fails, the remaining conditions aren’t checked.
Here’s a complete example showing how a custom ISBN scalar is defined, compiled, and validated:
import fraiseqlfrom fraiseql.scalars import CustomScalar, IDimport re
@fraiseql.scalarclass ISBN(CustomScalar): name = "ISBN"
def serialize(self, value: str) -> str: return str(value)
def parse_value(self, value: str) -> str: v = str(value).replace("-", "") if not re.match(r'^([0-9X]{10}|[0-9]{13})$', v): raise ValueError(f"Invalid ISBN: {value}") return str(value)
def parse_literal(self, ast) -> str: if hasattr(ast, 'value'): return self.parse_value(ast.value) raise ValueError("Invalid ISBN literal")
@fraiseql.typeclass Book: id: ID title: str isbn: ISBN author: str
@fraiseql.querydef book_by_isbn(isbn: ISBN) -> Book | None: return fraiseql.config(sql_source="v_book_by_isbn")$ fraiseql compile schema.json -o schema.compiled.json✅ Custom type 'ISBN' registered✅ Elo expression validated: (length(value) == 10...) || (length(value) == 13...)✅ 1 custom type compiled{ "custom_types": [ { "name": "ISBN", "description": "ISBN-10 or ISBN-13 format", "base_type": "String", "elo_expression": "(length(value) == 10 && matches(value, /^[0-9X]{10}$/)) || (length(value) == 13 && matches(value, /^978[0-9]{10}$|^979[0-9]{10}$/))", "specified_by_url": null } ], "types": { "Book": { "fields": { "isbn": { "type": "ISBN", "required": true } } } }}When a client queries:
query { bookByIsbn(isbn: "978-0-13-468599-1") { title author }}FraiseQL’s CustomTypeRegistry:
ISBN custom type definition(length("978-0-13-468599-1") == 10 ...) || ...length(...) == 13 → true, validates successfullyIf the client provides an invalid ISBN:
query { bookByIsbn(isbn: "not-an-isbn") { title }}The validator:
(length("not-an-isbn") == 10 ...) || ...falseValidation error: 'not-an-isbn' is not a valid ISBNThe Elo expressions below are used in the elo_expression field of the compiled CustomTypeDef. Each scalar is implemented as a CustomScalar subclass in Python (see the ISBN example above). The Elo expression shown is the validation rule embedded in the compiled schema.
matches(value, "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$")(length(value) == 10 && matches(value, "^[0-9X]{10}$")) ||(length(value) == 13 && matches(value, "^978[0-9]{10}$|^979[0-9]{10}$"))matches(value, "^https://[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}")length(value) >= 3 &&length(value) <= 20 &&matches(value, "^[a-zA-Z_][a-zA-Z0-9_]*$")value >= 0.01 && value <= 999999.99matches(value, "^[0-9]{3}-[0-9]{3}-[0-9]{4}$|^[0-9]{10}$")In mutation contexts, reference multiple fields from the validation context:
start_date < end_dateThe Elo evaluator resolves field names via dot-notation lookup in the JSON context passed at validation time.
The today() and now() functions are implemented and return the current date/timestamp:
value > today() # Date must be in the futureWhen FraiseQL compiles your schema, it:
schema.compiled.json for runtime useFraiseQL can also generate PostgreSQL CHECK constraints and JavaScript validation functions from Elo expressions at compile time:
fraiseql compile --emit-checks generates ALTER TABLE ... ADD CONSTRAINT statements for tb_* tables, enforcing Elo rules at the database level.fraiseql compile --emit-js generates a JavaScript module exporting a validation function per custom scalar, suitable for client-side pre-validation.Error: “Unknown function ‘validate‘“
# Wrongvalidate(value)
# Correct — use built-in functions: matches(), contains(), length(), today(), now()length(value) > 0Error: “Cannot compare string to number”
# Wrong - comparing int to stringvalue == "18"
# Correct - comparing int to intvalue == 18Patterns in matches() must be quoted strings, not regex literals:
# Wrong - slash-delimited regex literals are not supportedmatches(value, /^[a-z]+@[a-z]+.[a-z]+$/)
# Correct - use quoted string, escape backslashesmatches(value, "^[a-z]+@[a-z]+\\.[a-z]+$")Optimization tips:
CHECK constraints to your tb_* tables for high-volume database-level enforcementValidate expressions using the fraiseql compile CLI — it reports syntax errors and unknown function references at compile time. The fraiseql.validation and fraiseql.elo Python modules do not exist; expression testing is done through the CLI or by running fraiseql compile on your schema.
# Compile validates all Elo expressions and reports errorsfraiseql compile# Error: unknown function 'is_string' in elo_expression for scalar 'Email' at line 5Custom Scalar Types
Learn how to implement and configure custom scalars in your schema.
Semantic Scalars Reference
Explore built-in scalars you can extend with Elo.
Type System Guide
Understand FraiseQL’s type system and how types flow through layers.
Schema Definition
Define your GraphQL schema with Python, TypeScript, or Go.