Semantic Scalars Reference
Built-in Scalar Types — Explore 49 specialized types
Custom scalar types let you create domain-specific types that are validated at compile-time and enforced at runtime. This guide shows you how to design, implement, and deploy custom scalars effectively.
Custom scalars are ideal for:
| Scenario | Solution | Reason |
|---|---|---|
| Standard UUID | Use UUID semantic scalar | Built-in, optimized |
| Custom email with DNS check | Custom scalar with Elo | Domain-specific |
| Simple integer range | Custom scalar with Elo | Type-enforced validation |
| Complex business logic | Custom scalar + resolver | Combine with custom code |
| International phone | Custom scalar | Format flexibility |
| Timestamp with timezone | Use DateTime semantic scalar | Built-in support |
from fraiseql import scalar
@scalarclass Email(str): """Email address with RFC 5322 validation""" description = "Valid email address" specified_by_url = "https://datatracker.ietf.org/doc/html/rfc5322" elo_expression = 'matches(value, /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$/) && length(value) <= 254'import { scalar } from 'fraiseql';
export const Email = scalar<string>({ name: 'Email', description: 'Valid email address', specifiedByUrl: 'https://datatracker.ietf.org/doc/html/rfc5322', serialize: (value) => { if (typeof value !== 'string' || !value.includes('@')) { throw new Error('Invalid email format'); } return value.toLowerCase(); }, parseValue: (value) => { if (typeof value !== 'string' || !value.includes('@')) { throw new Error('Invalid email format'); } return value.toLowerCase(); },});"""Valid email address"""scalar Emailfrom fraiseql import type
@typeclass User: id: ID email: Email # Uses your custom scalar name: strimport { type } from 'fraiseql';import { Email } from './scalars';
@type()class User { id!: ID; email!: typeof Email; name!: string;}@scalarclass ISBN(str): """10 or 13 digit ISBN""" elo_expression = ''' (length(value) == 10 && matches(value, /^[0-9X]{10}$/)) || (length(value) == 13 && matches(value, /^[0-9]{13}$/)) '''import { scalar } from 'fraiseql';
export const ISBN = scalar<string>({ name: 'ISBN', description: '10 or 13 digit ISBN', serialize: (value) => { if (typeof value !== 'string') throw new Error('ISBN must be a string'); if (value.length !== 10 && value.length !== 13) throw new Error('ISBN must be 10 or 13 digits'); return value; }, parseValue: (value) => { if (typeof value !== 'string') throw new Error('ISBN must be a string'); if (value.length !== 10 && value.length !== 13) throw new Error('ISBN must be 10 or 13 digits'); return value; },});@scalarclass Price(float): description = "Price in USD, minimum $0.01, maximum $999,999.99" specified_by_url = "https://en.wikipedia.org/wiki/Price" elo_expression = 'value >= 0.01 && value <= 999999.99'export const Price = scalar<number>({ name: 'Price', description: 'Price in USD, minimum $0.01, maximum $999,999.99', specifiedByUrl: 'https://en.wikipedia.org/wiki/Price', serialize: (value) => { if (typeof value !== 'number' || value < 0.01 || value > 999999.99) { throw new Error('Price must be between $0.01 and $999,999.99'); } return value; }, parseValue: (value) => { if (typeof value !== 'number' || value < 0.01 || value > 999999.99) { throw new Error('Price must be between $0.01 and $999,999.99'); } return value; },});@scalarclass Percentage(float): """Percentage value 0-100""" elo_expression = 'value >= 0 && value <= 100'
@scalarclass NegativeInteger(int): """Negative integer (value < 0)""" elo_expression = 'value < 0'export const Percentage = scalar<number>({ name: 'Percentage', description: 'Percentage value 0-100', serialize: (value) => { if (typeof value !== 'number' || value < 0 || value > 100) { throw new Error('Percentage must be between 0 and 100'); } return value; }, parseValue: (value) => { if (typeof value !== 'number' || value < 0 || value > 100) { throw new Error('Percentage must be between 0 and 100'); } return value; },});
export const PositiveInt = scalar<number>({ name: 'PositiveInt', description: 'An integer greater than zero', serialize: (value) => { if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) { throw new Error('Must be a positive integer'); } return value; }, parseValue: (value) => { if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) { throw new Error('Must be a positive integer'); } return value; },});from fraiseql.scalars import Date
@scalarclass BirthDate(Date): """Legal birth date (person must be 18+)""" elo_expression = 'age(value) >= 18'For schema-less deployments or additional validation:
[[custom_scalars]]name = "Email"description = "Valid email address"base_type = "String"specified_by_url = "https://datatracker.ietf.org/doc/html/rfc5322"
[custom_scalars.elo]expression = 'matches(value, /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$/) && length(value) <= 254'
[[custom_scalars]]name = "ISBN"description = "ISBN-10 or ISBN-13"base_type = "String"
[custom_scalars.elo]expression = '''(length(value) == 10 && matches(value, /^[0-9X]{10}$/)) || (length(value) == 13 && matches(value, /^[0-9]{13}$/))'''@scalarclass Email(str): """Email address""" postgres_type = "VARCHAR(254)" postgres_check = "value ~ '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$'" elo_expression = 'matches(value, /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$/)'export const Email = scalar<string>({ name: 'Email', description: 'Email address', postgresType: 'VARCHAR(254)', postgresCheck: "value ~ '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$'", serialize: (value) => { if (typeof value !== 'string' || !value.includes('@')) throw new Error('Invalid email'); return value.toLowerCase(); }, parseValue: (value) => { if (typeof value !== 'string' || !value.includes('@')) throw new Error('Invalid email'); return value.toLowerCase(); },});FraiseQL generates:
CREATE DOMAIN email AS VARCHAR(254) CHECK (value ~ '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$');@scalarclass Price(float): """Price in USD""" mysql_type = "DECIMAL(10, 2)" mysql_check = "value > 0"export const Price = scalar<number>({ name: 'Price', description: 'Price in USD', mysqlType: 'DECIMAL(10, 2)', mysqlCheck: 'value > 0', serialize: (value) => { if (typeof value !== 'number' || value <= 0) throw new Error('Price must be positive'); return value; }, parseValue: (value) => { if (typeof value !== 'number' || value <= 0) throw new Error('Price must be positive'); return value; },});Generated:
CREATE TABLE products ( id INT PRIMARY KEY, price DECIMAL(10, 2) CHECK (price > 0));@scalarclass Percentage(float): """Percentage 0-100""" sqlite_type = "REAL" sqlite_check = "value >= 0 AND value <= 100"export const Percentage = scalar<number>({ name: 'Percentage', description: 'Percentage 0-100', sqliteType: 'REAL', sqliteCheck: 'value >= 0 AND value <= 100', serialize: (value) => { if (typeof value !== 'number' || value < 0 || value > 100) { throw new Error('Percentage must be 0-100'); } return value; }, parseValue: (value) => { if (typeof value !== 'number' || value < 0 || value > 100) { throw new Error('Percentage must be 0-100'); } return value; },});Generated:
CREATE TABLE metrics ( id INTEGER PRIMARY KEY, completion REAL CHECK (completion >= 0 AND completion <= 100));@scalarclass ISBN(str): """ISBN-10 or ISBN-13""" sqlserver_type = "VARCHAR(17)" sqlserver_check = "LEN(value) IN (10, 13)"export const ISBN = scalar<string>({ name: 'ISBN', description: 'ISBN-10 or ISBN-13', sqlServerType: 'VARCHAR(17)', sqlServerCheck: 'LEN(value) IN (10, 13)', serialize: (value) => { if (typeof value !== 'string' || (value.length !== 10 && value.length !== 13)) { throw new Error('ISBN must be 10 or 13 characters'); } return value; }, parseValue: (value) => { if (typeof value !== 'string' || (value.length !== 10 && value.length !== 13)) { throw new Error('ISBN must be 10 or 13 characters'); } return value; },});Generated:
ALTER TABLE booksADD CONSTRAINT chk_isbn CHECK (LEN(isbn) IN (10, 13));@scalarclass Email(str): """Email with basic RFC validation""" description = "Valid email address (DNS verification recommended at signup)" elo_expression = ''' matches(value, /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$/) && length(value) <= 254 && !matches(value, /^[.]|[.]@|\.{2}|@.*@|@$/) '''Use with a custom resolver for DNS verification:
from fraiseql import resolver
@resolverasync def validate_email(email: Email) -> bool: """DNS MX record verification""" domain = email.split('@')[1] try: import dns.resolver dns.resolver.resolve(domain, 'MX') return True except: return False@scalarclass PhoneNumber(str): """International phone number""" description = "Phone number in E.164 format (+1234567890)" elo_expression = 'matches(value, /^\\+[1-9]\\d{1,14}$/)'
@scalarclass USPhoneNumber(str): """US phone number""" elo_expression = 'matches(value, /^\\+1[0-9]{10}$|^[0-9]{3}-[0-9]{3}-[0-9]{4}$/)'
@scalarclass EUPhoneNumber(str): """European phone number""" elo_expression = 'matches(value, /^\\+[0-9]{1,3}[0-9]{6,14}$/)'@scalarclass CreditCardNumber(str): """Credit card number (16 digits, Luhn validated)""" description = "PCI-DSS compliant: validate with Luhn at API boundary" elo_expression = ''' (length(value) >= 13 && length(value) <= 19) && matches(value, /^[0-9]{13,19}$/) '''
@scalarclass CreditCardCVV(str): """Credit card CVV/CVC""" elo_expression = 'matches(value, /^[0-9]{3,4}$/)'from fraiseql import mutation
@mutationclass ProcessPayment: amount: Price card_token: str # Tokenized, never raw card data cvv_token: str # Tokenizedfrom fraiseql.scalars import UUID as SemanticUUID
@scalarclass UUIDv4(SemanticUUID): """UUID v4 (random)""" elo_expression = 'matches(value, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i)'
@scalarclass UUIDv5(SemanticUUID): """UUID v5 (name-based)""" elo_expression = 'matches(value, /^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i)'@scalarclass SSN(str): """US Social Security Number (format: XXX-XX-XXXX)""" description = "PII: Hash and encrypt in database" elo_expression = 'matches(value, /^[0-9]{3}-[0-9]{2}-[0-9]{4}$/) && value != "000-00-0000" && value != "666-00-0000"'Use with encryption:
from fraiseql.features import Encrypted
@typeclass Employee: id: ID ssn: Encrypted[SSN] # Encrypted at rest name: str@scalarclass IBAN(str): """International Bank Account Number""" description = "Validate format only; verify with bank" elo_expression = ''' (length(value) >= 15 && length(value) <= 34) && matches(value, /^[A-Z]{2}[0-9]{2}[A-Z0-9]{1,30}$/) '''import pytestfrom fraiseql.validation import compile_elo
def test_email_validation(): validator = compile_elo( 'matches(value, /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$/)', base_type='string' )
assert validator('user@example.com') == True assert validator('invalid.email') == False assert validator('test+tag@domain.co.uk') == True
def test_price_range(): validator = compile_elo('value >= 0.01 && value <= 999999.99', 'float')
assert validator(0.01) == True assert validator(100.50) == True assert validator(1000000.00) == False assert validator(0) == Falseimport pytestfrom fraiseql.testing import TestClient
def test_custom_scalar_mutation(client: TestClient): """Test that invalid custom scalar types are rejected""" result = client.execute(''' mutation { createUser(input: { email: "invalid-email" name: "Test User" }) { id email } } ''')
assert result['errors'] assert 'Email' in str(result['errors'])
def test_valid_custom_scalar(client: TestClient): """Test that valid values pass through""" result = client.execute(''' mutation { createUser(input: { email: "user@example.com" name: "Test User" }) { id email } } ''')
assert not result.get('errors') assert result['data']['createUser']['email'] == 'user@example.com'1. Use database constraints for high-volume data:
@scalarclass Price(float): """Use DB constraint for every row""" elo_expression = 'value > 0'2. Cache compiled validators:
[validation]cache_compiled_expressions = truecache_size = 100003. Order checks for short-circuit evaluation:
@scalarclass Email(str): # Check length first (fast), then regex (slower) elo_expression = 'length(value) <= 254 && matches(value, /...$/)'4. Avoid expensive operations in high-frequency mutations:
# Slow - complex regex in every write@scalarclass StrictEmail(str): elo_expression = 'matches(value, /^(?:[a-zA-Z0-9!#$%&\'*+/=?^_`{|}~-]+(?:\\.[a-zA-Z0-9...$/)'
# Fast - basic format check, DNS verify separately@scalarclass Email(str): elo_expression = 'matches(value, /^.+@.+\\..+$/)'5. Use semantic scalars when possible:
# Reinventing the wheel@scalarclass MyDate(str): elo_expression = 'matches(value, /^\\d{4}-\\d{2}-\\d{2}$/)'
# Use built-in semantic scalar insteadfrom fraiseql.scalars import Date# Already optimized for performanceError: “Invalid value for Email scalar”
Check your Elo expression syntax:
# Wrongelo_expression = 'email == valid' # No function 'valid'
# Correctelo_expression = 'matches(value, /^.+@.+$/) && length(value) > 0'Error: “Cannot convert string to custom scalar”
Ensure GraphQL provides the right type:
# Wrong - number isn't parsed as string scalarmutation { createUser(email: 123)}
# Correctmutation { createUser(email: "user@example.com")}Error: “Invalid regex pattern in Elo”
Escape special characters properly:
# Wrong - unescaped dotelo_expression = 'matches(value, /^[a-z]+@[a-z]+.[a-z]+$/)'
# Correct - escaped dotelo_expression = 'matches(value, /^[a-z]+@[a-z]+\\.[a-z]+$/)'Semantic Scalars Reference
Built-in Scalar Types — Explore 49 specialized types
Validation Rules
Validation Reference — All validation rule types
TOML Configuration
Configuration Reference — Configure validation