Skip to content

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

  1. Custom Serializer (required) - handles data conversion between Django and API
  2. 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:

  1. Custom Serializer (via Django settings) - for data handling
  2. 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

python
# settings.py
CUSTOM_FIELD_SERIALIZERS = {
    "myapp.fields.CustomField": "myapp.serializers.CustomFieldSerializer",
}

Step 2: Create Custom Config Class for Schema Override

python
# 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):

python
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:

python
# 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:

python
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

python
# 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 generates Record<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

python
# 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

  1. Serializer Registration: StateZero looks up custom serializers using the get_custom_serializer() function from your Django settings
  2. Schema Registration: Schema overrides are registered through custom config classes
  3. Priority: Settings-based serializers are checked after built-in serializers
  4. Application: Custom serializers are automatically applied to matching field types during model serialization
  5. Frontend Generation: Both the serialized format and schema definitions are used in API responses and frontend type generation

Troubleshooting

Common Issues

  1. Serializer not found: Check the module path in CUSTOM_FIELD_SERIALIZERS is correct
  2. Import errors: Ensure all dependencies are installed
  3. Validation errors: Add proper error handling in to_internal_value

Debug Steps

  1. Test your serializer independently with unit tests
  2. Check the Django field's full module path: print(f"{field.__module__}.{field.__class__.__name__}")
  3. Verify the serializer can be imported: from myapp.serializers import CustomFieldSerializer