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:
is_string(value)is_number(value)is_date(value)Date functions:
age(birth_date) >= 18 # Age in yearsage(birth_date) <= 120today() # Current dateNumeric 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_code
# Special variables_timestamp # When the record was created_user_id # ID of current userNumbers:
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.
from fraiseql import scalar
@scalarclass Email(str): """Email address with validation""" description = "Valid RFC 5322 email address" elo_expression = 'matches(value, /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$/) && length(value) <= 254'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"),}For deployments where you configure schemas with TOML instead of code:
[[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'
[[custom_types]]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}$/))'
[[custom_types]]name = "AdultBirthDate"description = "Birth date for users 18 or older"base_type = "Date"elo_expression = 'age(value) >= 18 && age(value) <= 150'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:
@scalarclass Password(str): """Secure password with minimum requirements""" description = "Password: 8+ chars, uppercase, lowercase, number, special char" 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.
Extend semantic scalars with additional Elo validation:
from fraiseql.scalars import Date
@scalarclass BirthDate(Date): """Birth date of a person (must be 18 or older)""" elo_expression = 'age(value) >= 18 && age(value) <= 120'In the compiled schema, BirthDate inherits from the Date base type but adds the Elo expression constraint.
Here’s a complete example showing how a custom ISBN scalar is defined, compiled, and validated:
from fraiseql import scalar, type, query
@scalarclass ISBN(str): """International Standard Book Number""" description = "ISBN-10 or ISBN-13 format" elo_expression = ''' (length(value) == 10 && matches(value, /^[0-9X]{10}$/)) || (length(value) == 13 && matches(value, /^978[0-9]{10}$|^979[0-9]{10}$/)) '''
@typeclass Book: id: ID title: str isbn: ISBN author: str
@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 ISBN@scalarclass Email(str): elo_expression = 'matches(value, /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$/) && length(value) <= 254'@scalarclass ISBN(str): """International Standard Book Number (ISBN-10 or ISBN-13)""" elo_expression = ''' (length(value) == 10 && matches(value, /^[0-9X]{10}$/)) || (length(value) == 13 && matches(value, /^978[0-9]{10}$|^979[0-9]{10}$/)) '''@scalarclass URL(str): """HTTPS URL""" elo_expression = 'matches(value, /^https:\/\/[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/) && length(value) >= 10 && length(value) <= 2048'@scalarclass AdultBirthDate(Date): """Birth date for adult users (18+)""" elo_expression = 'age(value) >= 18 && age(value) <= 150'
@scalarclass TeenBirthDate(Date): """Birth date for teen users (13-17)""" elo_expression = 'age(value) >= 13 && age(value) < 18'@scalarclass Username(str): """Username: 3-20 chars, alphanumeric + underscore, no leading digit""" elo_expression = ''' length(value) >= 3 && length(value) <= 20 && matches(value, /^[a-zA-Z_][a-zA-Z0-9_]*$/) '''@scalarclass Price(float): """Product price in USD (0.01 to 999,999.99)""" elo_expression = 'value >= 0.01 && value <= 999999.99'
@scalarclass Percentage(float): """Percentage value (0-100)""" elo_expression = 'value >= 0 && value <= 100'@scalarclass USPhoneNumber(str): """US phone number (10 digits)""" elo_expression = 'matches(value, /^[0-9]{3}-[0-9]{3}-[0-9]{4}$|^[0-9]{10}$/) && length(value) >= 10'In mutation contexts, reference multiple fields:
@scalarclass ValidDateRange(str): """Ensures start_date < end_date in mutations""" elo_expression = 'start_date < end_date'Use in a mutation:
@mutationclass CreateEvent: name: str start_date: Date end_date: Date date_validation: ValidDateRange # Triggers cross-field checkAccess user context in expressions:
@scalarclass AdminEmail(str): """Email restricted to admin users""" elo_expression = '_user_role == "admin" && matches(value, /@company\.com$/)'@scalarclass FutureDate(Date): """Date must be in the future""" elo_expression = 'value > today()'
@scalarclass RecentDate(Date): """Date must be within last 30 days""" elo_expression = 'age(today(), value) <= 30'When FraiseQL compiles your schema, it:
For example:
@scalarclass PositiveInteger(int): elo_expression = 'value > 0'FraiseQL generates:
CHECK (column > 0)(value) => value > 0Error: “Unknown function ‘validate‘“
# Wrongelo_expression = 'validate(value)'
# Correctelo_expression = 'length(value) > 0'Error: “Cannot compare string to number”
@scalarclass Age(int): # Wrong - comparing int to string elo_expression = 'value == "18"'
# Correct - comparing int to int elo_expression = 'value == 18'Error: “Invalid regex pattern”
@scalarclass Email(str): # Wrong - unescaped dots elo_expression = 'matches(value, /^[a-z]+@[a-z]+.[a-z]+$/)'
# Correct - escaped special chars elo_expression = 'matches(value, /^[a-z]+@[a-z]+\\.[a-z]+$/)'Optimization tips:
Validate expressions before deployment:
from fraiseql.validation import validate_elo
# Test expression compilesvalidate_elo('value > 0 && value < 100', base_type='int')
# Test with valuesfrom fraiseql.elo import compile_expressionvalidator = compile_expression('length(value) >= 3', 'string')assert validator('hello') == Trueassert validator('hi') == FalseCustom 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.