Skip to Content
FeaturesDRF GuideNested Serializers & allOf

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:

  1. read_only=True — adds readOnly property outside the $ref
  2. allow_null=True — adds nullable property outside the $ref
  3. Any custom field propertieshelp_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); // ✅ number

Decision Matrix

ScenarioSolutionType SafetyFlexibility
Dashboard/ReportsDictFieldLowHigh
CRUD APIsFlat SerializersHighLow
Mixed Read/WriteHybrid EndpointsHighHigh
Rapid PrototypingDictFieldLowHigh
Production FormsFlat SerializersHighLow

What Doesn’t Work

These approaches don’t fix the allOfRecord<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

  1. Use DictField for dashboard/reporting endpoints — avoids allOf entirely
  2. Provide typed sub-endpoints for critical data that needs frontend validation
  3. Don’t nest deeper than 2 levels — each level risks allOf wrapping
  4. Validate in services, not serializers — when using DictField
  5. Never manually edit generated/ — fix the serializers instead

Next Steps

Last updated on