Skip to content

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.

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.

  • Backend (.NET): [DerivedType] resolves a primary target from interface inference (or explicit targetType) and also auto-registers the derived type for non-system base classes.
  • Frontend (TypeScript): @derivedType registers identifiers on the concrete type and runtime prototype chain constructors, and JsonSerializer resolves candidates from both field.derivatives and DerivedType.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.

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.

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:

  1. Identify the correct type during deserialization
  2. Instantiate the proper class instead of a generic object
  3. Maintain type safety throughout the serialization process

Let’s walk through a complete example showing data flow from backend to frontend and back.

[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" }
});
}
}

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;
}
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 component
const paymentService = new PaymentService();
// Create a CreditCard payment
const creditCard = new CreditCard();
creditCard.amount = 99.99;
creditCard.cardNumber = '1234-5678-9012-3456';
creditCard.expiryDate = '12/25';
// Process the payment - types are preserved throughout
const result = await paymentService.processPayment(creditCard);
// result.paymentMethod will be a CreditCard instance
if (result.paymentMethod instanceof CreditCard) {
console.log(`Processed card ending in ${result.paymentMethod.cardNumber.slice(-4)}`);
}
DerivedTypeConverterBackendNetworkJsonSerializerFrontendDerivedTypeConverterBackendNetworkJsonSerializerFrontendserialize(creditCard)Add _derivedTypeId from @derivedTypeJSON with type infoPOST /api/payment/processDeserialize PaymentRequestRead _derivedTypeIdInstantiate CreditCardTyped PaymentRequest
FrontendJsonSerializerNetworkDerivedTypeConverterBackendFrontendJsonSerializerNetworkDerivedTypeConverterBackendSerialize PaymentResultAdd _derivedTypeId from [DerivedType]JSON with type infoJSON responseRead _derivedTypeIdInstantiate CreditCardTyped PaymentResult
// In Program.cs or Startup.cs
services.Configure<JsonSerializerOptions>(options =>
{
options.Converters.Add(new DerivedTypeJsonConverterFactory());
options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; // Match frontend
});
// The DerivedTypes system is automatically registered as a singleton
services.AddSingleton<IDerivedTypes, DerivedTypes>();
// Ensure reflect-metadata is imported early
import 'reflect-metadata';
// Configure TypeScript
// tsconfig.json
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}

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.

Maintain a shared understanding of types:

// Consider generating TypeScript models from C# classes
// Or maintain parallel class definitions with identical structures

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.

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"

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");
}

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');
});
});

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).

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.

Problem: Frontend field decorators missing derivatives parameter. Solution: Always specify derivatives for polymorphic fields.

Problem: Property names don’t match between frontend and backend. Solution: Configure consistent casing policies.

Problem: TypeScript properties without @field decorators aren’t serialized. Solution: Decorate all serializable properties.

Problem: Derived types not registered before serialization. Solution: Ensure all classes are imported before use.

// Check if a type is registered
var derivedTypes = serviceProvider.GetService<IDerivedTypes>();
var isRegistered = derivedTypes.IsDerivedType(typeof(CreditCard));
// Get target type for derived type
var targetType = derivedTypes.GetTargetTypeFor(typeof(CreditCard));
// Check derived type registration
const derivedTypeId = DerivedType.get(CreditCard);
console.log('CreditCard ID:', derivedTypeId);
// Check field metadata
const fields = Fields.getFieldsForType(PaymentRequest);
console.log('PaymentRequest fields:', fields);
// Inspect JSON before sending
const 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.