Adding Custom Field Types
Custom field types allow you to extend StateZero's serialization capabilities for specialized Django model fields. StateZero already includes built-in support for common field types like Money fields, File fields, and Image fields.
What You Need
- Custom Serializer (required) - handles data conversion between Django and API
- Schema Override (optional but recommended) - provides proper TypeScript types
Without a schema override, your field will work but will be typed as Record<string, any>
in TypeScript.
Configuration
Custom field types require two parts:
- Custom Serializer (via Django settings) - for data handling
- Schema Override (via custom config class) - for TypeScript type generation
Note: Unfortunately, schema overrides cannot be configured via Django settings like serializers can. For now, you must use a custom config class. We plan to add settings-based schema override support in a future release.
Step 1: Add Serializer to Django Settings
# settings.py
CUSTOM_FIELD_SERIALIZERS = {
"myapp.fields.CustomField": "myapp.serializers.CustomFieldSerializer",
}
Step 2: Create Custom Config Class for Schema Override
# myapp/config.py
from statezero.adaptors.django.config import DjangoLocalConfig
from myapp.schemas import CustomFieldSchema
from myapp.fields import CustomField
class MyAppConfig(DjangoLocalConfig):
def initialize(self):
super().initialize()
# Register schema overrides (cannot be done via settings)
self.schema_overrides.update({
CustomField: CustomFieldSchema,
})
# settings.py
STATEZERO_CUSTOM_CONFIG = "myapp.config.MyAppConfig"
Creating Custom Serializers
Custom serializers handle data conversion between your Django field and the API. They must inherit from rest_framework.serializers.Field
.
Here's how the built-in Money field serializer works (already included in StateZero):
from decimal import Decimal, InvalidOperation
from djmoney.money import Money
from rest_framework import serializers
class MoneyFieldSerializer(serializers.Field):
def __init__(self, **kwargs):
self.max_digits = kwargs.pop("max_digits", 14)
self.decimal_places = kwargs.pop("decimal_places", 2)
super().__init__(**kwargs)
def to_representation(self, value):
"""Convert Money object to API format"""
if not value:
return None
return {
"amount": float(value.amount),
"currency": value.currency.code
}
def to_internal_value(self, data):
"""Convert API input to Money object"""
if isinstance(data, Money):
return data
if not isinstance(data, dict):
raise serializers.ValidationError(
"Input must be an object with 'amount' and 'currency' keys"
)
try:
amount = Decimal(str(data["amount"]))
currency = data["currency"]
return Money(amount, currency)
except KeyError:
raise serializers.ValidationError("Missing 'amount' or 'currency'")
except (InvalidOperation, ValueError):
raise serializers.ValidationError("Invalid amount format")
Your Custom Serializer Template
For your own custom fields, follow this pattern:
# myapp/serializers.py
from rest_framework import serializers
class CustomFieldSerializer(serializers.Field):
def to_representation(self, value):
"""Convert your field value to API response format"""
if not value:
return None
# Return the format your frontend expects
return {
'display_value': str(value),
'raw_data': value.get_raw_data() if hasattr(value, 'get_raw_data') else None
}
def to_internal_value(self, data):
"""Convert API input to your field value"""
if not data:
return None
# Handle different input formats
if isinstance(data, dict) and 'display_value' in data:
return self.create_field_value(data['display_value'])
elif isinstance(data, str):
return self.create_field_value(data)
else:
raise serializers.ValidationError("Invalid input format")
def create_field_value(self, raw_value):
"""Helper to create your field's value object"""
# Your custom logic here
return YourFieldValueClass(raw_value)
Schema Overrides (For Proper TypeScript Types)
If you want proper TypeScript types instead of Record<string, any>
, create a schema override.
Here's how the built-in Money field schema works:
from typing import Dict, Tuple
from statezero.core.classes import FieldFormat, FieldType, SchemaFieldMetadata
from statezero.core.interfaces import AbstractSchemaOverride
class MoneyFieldSchema(AbstractSchemaOverride):
def __init__(self, *args, **kwargs):
pass
def get_schema(self, field) -> Tuple[SchemaFieldMetadata, Dict[str, str], str]:
"""Generate schema for MoneyField"""
key = field.__class__.__name__ # "MoneyField"
# Define the shape of the data
definition = {
"type": "object",
"properties": {
"amount": {"type": "number"},
"currency": {"type": "string"},
},
}
# Create schema metadata
schema = SchemaFieldMetadata(
type=FieldType.OBJECT,
title="Money",
required=not field.null,
nullable=field.null,
format=FieldFormat.MONEY,
description=field.help_text or "Money field",
ref=f"#/components/schemas/{key}",
)
return schema, definition, key
Your Custom Schema Override
# myapp/schemas.py
from typing import Dict, Tuple
from statezero.core.classes import FieldFormat, FieldType, SchemaFieldMetadata
from statezero.core.interfaces import AbstractSchemaOverride
class CustomFieldSchema(AbstractSchemaOverride):
def __init__(self, *args, **kwargs):
pass
def get_schema(self, field) -> Tuple[SchemaFieldMetadata, Dict[str, str], str]:
"""Generate schema for your custom field"""
key = field.__class__.__name__
# Define the TypeScript interface shape
definition = {
"type": "object",
"properties": {
"display_value": {"type": "string"},
"raw_data": {"type": "object", "nullable": True}
},
"required": ["display_value"]
}
schema = SchemaFieldMetadata(
type=FieldType.OBJECT,
title="Custom Field",
required=not field.null,
nullable=field.null,
format=FieldFormat.JSON, # or create a custom format
description=field.help_text or "Custom field type",
ref=f"#/components/schemas/{key}",
)
return schema, definition, key
Default Behavior
If you don't provide custom serialization for a field type, StateZero will:
- Serialization: Use Django REST Framework's default field mapping (usually works fine)
- Schema Generation: Default to
FieldType.OBJECT
with no specific format, which generatesRecord<string, any>
in TypeScript
This means unknown field types will work for basic data handling, but won't have proper TypeScript types.
Built-in Field Types
StateZero already includes custom serializers and schemas for:
- Money Fields (
MoneyField
): Handles currency and decimal precision with proper TypeScript types - File Fields (
FileField
): Handles file paths, metadata, and URLs - Image Fields (
ImageField
): Extends file handling with image validation
You don't need to configure these - they work automatically with full frontend type support.
Testing
# tests/test_custom_fields.py
from django.test import TestCase
from myapp.serializers import CustomFieldSerializer
class CustomFieldTest(TestCase):
def test_serializer_representation(self):
field_value = YourFieldValueClass("test")
serializer = CustomFieldSerializer()
result = serializer.to_representation(field_value)
self.assertEqual(result['display_value'], "test")
def test_serializer_internal_value(self):
input_data = {'display_value': 'test'}
serializer = CustomFieldSerializer()
result = serializer.to_internal_value(input_data)
self.assertEqual(str(result), "test")
How It Works
- Serializer Registration: StateZero looks up custom serializers using the
get_custom_serializer()
function from your Django settings - Schema Registration: Schema overrides are registered through custom config classes
- Priority: Settings-based serializers are checked after built-in serializers
- Application: Custom serializers are automatically applied to matching field types during model serialization
- Frontend Generation: Both the serialized format and schema definitions are used in API responses and frontend type generation
Troubleshooting
Common Issues
- Serializer not found: Check the module path in
CUSTOM_FIELD_SERIALIZERS
is correct - Import errors: Ensure all dependencies are installed
- Validation errors: Add proper error handling in
to_internal_value
Debug Steps
- Test your serializer independently with unit tests
- Check the Django field's full module path:
print(f"{field.__module__}.{field.__class__.__name__}")
- Verify the serializer can be imported:
from myapp.serializers import CustomFieldSerializer