Nested Serializers & allOf
When using nested serializers, drf-spectacular generates allOf constructs that the TypeScript generator converts to Record<string, any>, losing type safety. This page explains why and how to work around it.
The Problem
Django serializer:
class DashboardOverviewSerializer(serializers.Serializer):
system_metrics = SystemMetricsSerializer(help_text="System metrics")
user_statistics = UserStatisticsSerializer(help_text="User stats")Generated OpenAPI schema:
DashboardOverview:
properties:
system_metrics:
allOf:
- $ref: "#/components/schemas/SystemMetrics"
user_statistics:
allOf:
- $ref: "#/components/schemas/UserStatistics"Generated TypeScript (broken):
export interface DashboardOverview {
system_metrics: Record<string, any>; // ❌ Lost type safety!
user_statistics: Record<string, any>; // ❌ Lost type safety!
}Why allOf Appears
DRF Spectacular wraps nested serializer references in allOf when:
read_only=True— addsreadOnlyproperty outside the$refallow_null=True— addsnullableproperty outside the$ref- Any custom field properties —
help_text,required, etc.
The TypeScript generator doesn’t resolve single-item allOf to a direct $ref.
Solutions
Solution 1: DictField (for flexible responses)
Best for dashboard/reporting endpoints where strict typing isn’t critical:
class DashboardOverviewSerializer(serializers.Serializer):
stat_cards = serializers.ListField(
child=serializers.DictField(),
help_text="Dashboard statistics cards"
)
system_metrics = serializers.DictField(
help_text="System performance metrics"
)
user_statistics = serializers.DictField(
help_text="User statistics"
)Generated TypeScript:
export interface DashboardOverview {
stat_cards: Array<Record<string, any>>;
system_metrics: Record<string, any>;
user_statistics: Record<string, any>;
}When to use:
- Dashboard and reporting endpoints
- Data aggregation APIs
- Read-only responses without ORM models
- Rapid prototyping
Solution 2: Flat serializers (type-safe)
Best for CRUD APIs where strict typing matters:
# Instead of nesting SystemMetricsSerializer:
class DashboardOverviewSerializer(serializers.Serializer):
# Inline system metrics fields
cpu_usage = serializers.FloatField()
memory_usage = serializers.FloatField()
disk_usage = serializers.FloatField()
# Inline user statistics fields
total_users = serializers.IntegerField()
active_users = serializers.IntegerField()Generated TypeScript:
export interface DashboardOverview {
cpu_usage: number;
memory_usage: number;
disk_usage: number;
total_users: number;
active_users: number;
}When to use:
- CRUD operations
- Form submissions
- APIs that need strict frontend validation
Solution 3: Separate typed endpoints (hybrid)
Provide both a flexible overview and typed sub-endpoints:
class DashboardViewSet(viewsets.GenericViewSet):
serializer_class = DashboardOverviewSerializer
# Flexible overview — Record<string, any> on frontend
@extend_schema(responses={200: DashboardOverviewSerializer}, tags=["dashboard"])
@action(detail=False, methods=['get'])
def overview(self, request):
return Response({
'stat_cards': service.get_stat_cards(),
'system_metrics': service.get_system_metrics(),
})
# Typed endpoint — SystemMetrics type on frontend
@extend_schema(responses={200: SystemMetricsSerializer}, tags=["dashboard"])
@action(detail=False, methods=['get'])
def system_metrics(self, request):
return Response(service.get_system_metrics())Frontend usage:
// Flexible overview
const overview = await api.dashboard.overview();
const cards = overview.stat_cards; // Record<string, any>[]
// Type-safe specific endpoint
const metrics = await api.dashboard.systemMetrics();
console.log(metrics.cpu_usage); // ✅ numberDecision Matrix
| Scenario | Solution | Type Safety | Flexibility |
|---|---|---|---|
| Dashboard/Reports | DictField | Low | High |
| CRUD APIs | Flat Serializers | High | Low |
| Mixed Read/Write | Hybrid Endpoints | High | High |
| Rapid Prototyping | DictField | Low | High |
| Production Forms | Flat Serializers | High | Low |
What Doesn’t Work
These approaches don’t fix the allOf → Record<string, any> issue:
# ❌ Still generates allOf
system_metrics = SystemMetricsSerializer(read_only=True)
# ❌ Ignored by drf-spectacular
@extend_schema(
responses={200: inline_serializer(
name='Overview',
fields={'metrics': SystemMetricsSerializer()}
)}
)
# ❌ Creates inline schema, not named component
@extend_schema(
responses={200: {
'type': 'object',
'properties': {
'metrics': {'$ref': '#/components/schemas/SystemMetrics'}
}
}}
)Best Practices
- Use DictField for dashboard/reporting endpoints — avoids
allOfentirely - Provide typed sub-endpoints for critical data that needs frontend validation
- Don’t nest deeper than 2 levels — each level risks
allOfwrapping - Validate in services, not serializers — when using DictField
- Never manually edit
generated/— fix the serializers instead
Next Steps
- DRF ViewSets & Serializers — General ViewSet patterns
- Array Responses — Returning arrays from
@actionmethods - Pagination — Pagination patterns for ViewSet actions