DerivedTypes Integration: Backend and Frontend
This document explains how the DerivedTypes serialization systems in the .NET backend and TypeScript frontend work together to provide seamless polymorphic serialization across your full-stack application.
Overview
Section titled “Overview”The DerivedTypes system creates a bridge between strongly-typed C# classes and TypeScript classes, enabling type-safe polymorphic serialization across the client-server boundary. Both systems use identical JSON representations and type identifiers to ensure compatibility.
Shared Concepts
Section titled “Shared Concepts”Target Type Resolution Across Stacks
Section titled “Target Type Resolution Across Stacks”- Backend (.NET):
[DerivedType]resolves a primary target from interface inference (or explicittargetType) and also auto-registers the derived type for non-system base classes. - Frontend (TypeScript):
@derivedTyperegisters identifiers on the concrete type and runtime prototype chain constructors, andJsonSerializerresolves candidates from bothfield.derivativesandDerivedType.getDerivedTypesFor(field.type).
This means polymorphic fields typed as base classes work end-to-end without requiring explicit target type wiring in most cases. Interface-only fields still need explicit runtime candidates.
Unique Type Identifiers
Section titled “Unique Type Identifiers”Both backend and frontend use unique string identifiers that must match exactly:
Backend (.NET):
[DerivedType("credit-card")]public class CreditCard : IPaymentMethod{ public decimal Amount { get; set; } public string CardNumber { get; set; }}Frontend (TypeScript):
@derivedType('credit-card')export class CreditCard implements IPaymentMethod { @field(Number) amount!: number;
@field(String) cardNumber!: string;}Uniqueness Requirement: Derived type IDs must be unique per interface. Use descriptive strings (e.g.,
'credit-card','paypal') for readability, or GUIDs for maximum uniqueness (though with reduced readability). Whatever format you choose, ensure these identifiers are centralized in shared constants to maintain consistency across both backend and frontend.
JSON Wire Format
Section titled “JSON Wire Format”Both systems produce and consume identical JSON:
{ "paymentMethod": { "amount": 99.99, "cardNumber": "****-1234", "_derivedTypeId": "credit-card" }}The _derivedTypeId property is the key that allows both systems to:
- Identify the correct type during deserialization
- Instantiate the proper class instead of a generic object
- Maintain type safety throughout the serialization process
Full-Stack Example
Section titled “Full-Stack Example”Let’s walk through a complete example showing data flow from backend to frontend and back.
1. Backend API Endpoint
Section titled “1. Backend API Endpoint”[ApiController][Route("api/[controller]")]public class PaymentController : ControllerBase{ [HttpPost("process")] public ActionResult<PaymentResult> ProcessPayment([FromBody] PaymentRequest request) { // request.PaymentMethod will be properly deserialized to CreditCard or PayPal var result = _paymentService.ProcessPayment(request.PaymentMethod);
return Ok(new PaymentResult { Success = result.Success, PaymentMethod = request.PaymentMethod // Will serialize with _derivedTypeId }); }
[HttpGet("methods")] public ActionResult<IEnumerable<IPaymentMethod>> GetAvailablePaymentMethods() { return Ok(new IPaymentMethod[] { new CreditCard { Amount = 0, CardNumber = "Demo" }, new PayPal { Amount = 0, Email = "demo@example.com" } }); }}2. Shared Models
Section titled “2. Shared Models”Backend Models:
public interface IPaymentMethod{ decimal Amount { get; set; }}
[DerivedType("credit-card")]public class CreditCard : IPaymentMethod{ public decimal Amount { get; set; } public string CardNumber { get; set; } public string ExpiryDate { get; set; }}
[DerivedType("paypal")]public class PayPal : IPaymentMethod{ public decimal Amount { get; set; } public string Email { get; set; }}
public class PaymentRequest{ public string OrderId { get; set; } public IPaymentMethod PaymentMethod { get; set; }}
public class PaymentResult{ public bool Success { get; set; } public IPaymentMethod PaymentMethod { get; set; }}Frontend Models (mirroring backend):
export interface IPaymentMethod { amount: number;}
@derivedType('credit-card')export class CreditCard implements IPaymentMethod { @field(Number) amount!: number;
@field(String) cardNumber!: string;
@field(String) expiryDate!: string;}
@derivedType('paypal')export class PayPal implements IPaymentMethod { @field(Number) amount!: number;
@field(String) email!: string;}
export class PaymentRequest { @field(String) orderId!: string;
@field(Object, false, [CreditCard, PayPal]) paymentMethod!: IPaymentMethod;}
export class PaymentResult { @field(Boolean) success!: boolean;
@field(Object, false, [CreditCard, PayPal]) paymentMethod!: IPaymentMethod;}3. Frontend Usage
Section titled “3. Frontend Usage”class PaymentService { async processPayment(paymentMethod: IPaymentMethod): Promise<PaymentResult> { const request = new PaymentRequest(); request.orderId = '12345'; request.paymentMethod = paymentMethod;
// Serialize with type information const json = JsonSerializer.serialize(request);
const response = await fetch('/api/payment/process', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: json });
const responseJson = await response.text();
// Deserialize with proper types return JsonSerializer.deserialize(PaymentResult, responseJson); }
async getAvailablePaymentMethods(): Promise<IPaymentMethod[]> { const response = await fetch('/api/payment/methods'); const json = await response.text();
// Each item will be properly typed as CreditCard or PayPal return JsonSerializer.deserializeArray(Object, json) .map(item => { // The JsonSerializer will have already created the correct instances return item as IPaymentMethod; }); }}
// Usage in componentconst paymentService = new PaymentService();
// Create a CreditCard paymentconst creditCard = new CreditCard();creditCard.amount = 99.99;creditCard.cardNumber = '1234-5678-9012-3456';creditCard.expiryDate = '12/25';
// Process the payment - types are preserved throughoutconst result = await paymentService.processPayment(creditCard);
// result.paymentMethod will be a CreditCard instanceif (result.paymentMethod instanceof CreditCard) { console.log(`Processed card ending in ${result.paymentMethod.cardNumber.slice(-4)}`);}Data Flow
Section titled “Data Flow”1. Frontend to Backend (Serialization)
Section titled “1. Frontend to Backend (Serialization)”2. Backend to Frontend (Deserialization)
Section titled “2. Backend to Frontend (Deserialization)”Configuration
Section titled “Configuration”Backend Configuration
Section titled “Backend Configuration”// In Program.cs or Startup.csservices.Configure<JsonSerializerOptions>(options =>{ options.Converters.Add(new DerivedTypeJsonConverterFactory()); options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; // Match frontend});
// The DerivedTypes system is automatically registered as a singletonservices.AddSingleton<IDerivedTypes, DerivedTypes>();Frontend Configuration
Section titled “Frontend Configuration”// Ensure reflect-metadata is imported earlyimport 'reflect-metadata';
// Configure TypeScript// tsconfig.json{ "compilerOptions": { "experimentalDecorators": true, "emitDecoratorMetadata": true }}Best Practices for Full-Stack Integration
Section titled “Best Practices for Full-Stack Integration”Prefer Concrete Base Types for Polymorphic Properties
Section titled “Prefer Concrete Base Types for Polymorphic Properties”When your domain has a stable base class, prefer using that base class as the property type. This gives runtime constructors on both stacks, which simplifies polymorphic resolution and reduces manual derivative wiring.
Use interface-typed properties when needed, and then provide explicit derivative candidates in frontend field metadata and explicit target type where ambiguity exists.
1. Synchronized Type Definitions
Section titled “1. Synchronized Type Definitions”Maintain a shared understanding of types:
// Consider generating TypeScript models from C# classes// Or maintain parallel class definitions with identical structures2. Consistent Naming Conventions
Section titled “2. Consistent Naming Conventions”Configure JSON serialization to use consistent casing:
Backend:
options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;Frontend:
// Field decorators already use camelCase property names@field(String)cardNumber!: string; // Serializes as "cardNumber"3. Centralize Type Identifiers (Magic Strings)
Section titled “3. Centralize Type Identifiers (Magic Strings)”Store derived type identifiers in shared constants to ensure consistency and ease maintenance:
Backend (C#):
public static class DerivedTypeIds{ public const string CreditCard = "credit-card"; public const string PayPal = "paypal"; public const string BankTransfer = "bank-transfer";}
[DerivedType(DerivedTypeIds.CreditCard)]public class CreditCard : IPaymentMethod { }
[DerivedType(DerivedTypeIds.PayPal)]public class PayPal : IPaymentMethod { }Frontend (TypeScript):
export const DERIVED_TYPE_IDS = { CREDIT_CARD: 'credit-card', PAYPAL: 'paypal', BANK_TRANSFER: 'bank-transfer',} as const;
@derivedType(DERIVED_TYPE_IDS.CREDIT_CARD)export class CreditCard implements IPaymentMethod { }
@derivedType(DERIVED_TYPE_IDS.PAYPAL)export class PayPal implements IPaymentMethod { }Why centralize? These “magic strings” are critical for serialization consistency. Centralizing them prevents duplicates across your codebase, makes it easy to maintain and update, and ensures the frontend and backend remain synchronized.
4. Consistent Naming Conventions
Section titled “4. Consistent Naming Conventions”Configure JSON serialization to use consistent casing:
Backend:
options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;Frontend:
// Field decorators already use camelCase property names@field(String)cardNumber!: string; // Serializes as "cardNumber"5. Error Handling
Section titled “5. Error Handling”Handle deserialization failures gracefully:
Frontend:
try { const result = JsonSerializer.deserialize(PaymentResult, json); return result;} catch (error) { console.error('Failed to deserialize payment result:', error); // Handle unknown types or malformed data return null;}Backend:
try{ var request = JsonSerializer.Deserialize<PaymentRequest>(json, options); return request;}catch (JsonException ex){ _logger.LogError(ex, "Failed to deserialize payment request"); return BadRequest("Invalid request format");}6. Testing Integration
Section titled “6. Testing Integration”Test the complete round-trip:
describe('Payment integration', () => { it('should handle credit card round-trip', async () => { // Create frontend object const originalCard = new CreditCard(); originalCard.amount = 99.99; originalCard.cardNumber = '1234';
// Serialize for backend const json = JsonSerializer.serialize(originalCard);
// Simulate backend processing (you might use a real API in integration tests) const backendResponse = await simulateBackendProcessing(json);
// Deserialize backend response const result = JsonSerializer.deserialize(PaymentResult, backendResponse);
// Verify type preservation expect(result.paymentMethod instanceof CreditCard).toBe(true); expect((result.paymentMethod as CreditCard).cardNumber).toBe('1234'); });});Common Pitfalls
Section titled “Common Pitfalls”1. Mismatched Type IDs
Section titled “1. Mismatched Type IDs”Problem: Frontend and backend have different identifiers for the same type.
Solution: Use shared constants files to centralize derived type IDs (e.g., DerivedTypeIds.cs and derivedTypeIds.ts).
2. Duplicate Magic Strings
Section titled “2. Duplicate Magic Strings”Problem: Type identifiers duplicated across multiple files, making updates error-prone. Solution: Centralize all derived type IDs in a single shared location and import from there.
3. Missing Derivatives Lists
Section titled “3. Missing Derivatives Lists”Problem: Frontend field decorators missing derivatives parameter. Solution: Always specify derivatives for polymorphic fields.
4. Case Sensitivity
Section titled “4. Case Sensitivity”Problem: Property names don’t match between frontend and backend. Solution: Configure consistent casing policies.
5. Missing Field Decorators
Section titled “5. Missing Field Decorators”Problem: TypeScript properties without @field decorators aren’t serialized.
Solution: Decorate all serializable properties.
6. Type Registration Order
Section titled “6. Type Registration Order”Problem: Derived types not registered before serialization. Solution: Ensure all classes are imported before use.
Debugging
Section titled “Debugging”Backend Debugging
Section titled “Backend Debugging”// Check if a type is registeredvar derivedTypes = serviceProvider.GetService<IDerivedTypes>();var isRegistered = derivedTypes.IsDerivedType(typeof(CreditCard));
// Get target type for derived typevar targetType = derivedTypes.GetTargetTypeFor(typeof(CreditCard));Frontend Debugging
Section titled “Frontend Debugging”// Check derived type registrationconst derivedTypeId = DerivedType.get(CreditCard);console.log('CreditCard ID:', derivedTypeId);
// Check field metadataconst fields = Fields.getFieldsForType(PaymentRequest);console.log('PaymentRequest fields:', fields);
// Inspect JSON before sendingconst json = JsonSerializer.serialize(paymentRequest);console.log('Outgoing JSON:', json);This integration enables robust, type-safe communication between your .NET backend and TypeScript frontend while maintaining the flexibility of polymorphic serialization.