ConceptAs
The ConceptAs<T> abstract class provides a TypeScript equivalent to the C# ConceptAs<T>, allowing you to create strongly-typed domain identifiers and value objects that wrap primitive types such as string, number, or boolean.
The recommended pattern is to export concepts as union types for convenient assignment of both concept instances and primitives:
import { ConceptAs } from '@cratis/fundamentals';
class UserIdConcept extends ConceptAs<string> {}export type UserId = UserIdConcept | string;
class OrderCountConcept extends ConceptAs<number> {}export type OrderCount = OrderCountConcept | number;Overview
Section titled “Overview”ConceptAs is part of Domain-Driven Design (DDD) patterns for creating ubiquitous language in your codebase. Instead of using raw primitive types like string or number for domain-specific values, you create explicit types that better express their purpose and prevent mix-ups.
Key Benefits
Section titled “Key Benefits”1. Type Safety
Section titled “1. Type Safety”Instead of passing around generic strings or numbers, you create strongly-typed domain concepts:
// Without ConceptAs - prone to errorsfunction getUser(id: string) { ... }function getOrder(id: string) { ... }
// With ConceptAs - compile-time safetyclass UserIdConcept extends ConceptAs<string> {}export type UserId = UserIdConcept | string;
class OrderIdConcept extends ConceptAs<string> {}export type OrderId = OrderIdConcept | string;
function getUser(id: UserId) { ... }function getOrder(id: OrderId) { ... }
// This would be a compile-time error:const userId = new UserIdConcept("user-123");getOrder(userId); // ❌ Type error - OrderId expected2. Self-Documenting Code
Section titled “2. Self-Documenting Code”The type names make the code more readable and self-explanatory:
// Before: What does this string represent?function createInvoice(customerId: string, amount: number) { ... }
// After: Clear semantic meaningclass CustomerIdConcept extends ConceptAs<string> {}export type CustomerId = CustomerIdConcept | string;
class InvoiceAmountConcept extends ConceptAs<number> {}export type InvoiceAmount = InvoiceAmountConcept | number;
function createInvoice(customerId: CustomerId, amount: InvoiceAmount) { ... }3. JSON Serialization Support
Section titled “3. JSON Serialization Support”ConceptAs types integrate seamlessly with the JsonSerializer, automatically serializing to their inner values and deserializing back to typed instances:
import { ConceptAs, JsonSerializer, field } from '@cratis/fundamentals';
class UserIdConcept extends ConceptAs<string> {}export type UserId = UserIdConcept | string;
class User { @field(UserIdConcept) id!: UserId;
@field(String) name!: string;}
// Serializationconst user = new User();user.id = new UserIdConcept('user-123'); // or just 'user-123'user.name = 'John Doe';
const json = JsonSerializer.serialize(user);// Result: {"id":"user-123","name":"John Doe"}
// Deserializationconst deserialized = JsonSerializer.deserialize(User, json);console.log(deserialized.id instanceof UserIdConcept); // ✅ trueconsole.log((deserialized.id as UserIdConcept).value); // "user-123"Creating a ConceptAs Type
Section titled “Creating a ConceptAs Type”To create a ConceptAs type, extend the ConceptAs<T> abstract class with the appropriate primitive type. For convenience and ease of reading, export the concept as a union type that includes both the concept class and the underlying primitive:
import { ConceptAs } from '@cratis/fundamentals';
class UserIdConcept extends ConceptAs<string> {}export type UserId = UserIdConcept | string;
class OrderCountConcept extends ConceptAs<number> {}export type OrderCount = OrderCountConcept | number;
class IsActiveConcept extends ConceptAs<boolean> {}export type IsActive = IsActiveConcept | boolean;This pattern provides:
- Convenience: Consumers can assign either the concept instance or the primitive value
- Readability: The exported type name is clean and matches domain language
- Type Safety: The
@fielddecorator metadata ensures correct serialization/deserialization
Using ConceptAs Types
Section titled “Using ConceptAs Types”Instantiate your concept types by passing the underlying value. With the union type export pattern, you can assign either concept instances or primitive values:
// Instantiate the concept classconst userId = new UserIdConcept('user-123');const orderCount = new OrderCountConcept(42);const isActive = new IsActiveConcept(true);
// Access the underlying valueconsole.log(userId.value); // "user-123"console.log(orderCount.value); // 42
// Get primitive value for operationsconsole.log(userId.valueOf()); // "user-123"
// String representationconsole.log(userId.toString()); // "user-123"console.log(orderCount.toString()); // "42"
// With union types, you can also assign primitives directlylet flexibleUserId: UserId = 'user-456'; // Works!flexibleUserId = new UserIdConcept('user-789'); // Also works!Using with JsonSerializer
Section titled “Using with JsonSerializer”To use ConceptAs types in objects that will be serialized/deserialized, mark the fields with the @field decorator. The @field decorator takes the concept class (not the union type), ensuring proper deserialization:
import { ConceptAs, JsonSerializer, field } from '@cratis/fundamentals';
// Define concepts with union type exportsclass UserIdConcept extends ConceptAs<string> {}export type UserId = UserIdConcept | string;
class OrderIdConcept extends ConceptAs<string> {}export type OrderId = OrderIdConcept | string;
class Order { // @field takes the class, type annotation uses the exported union type @field(OrderIdConcept) orderId!: OrderId;
@field(UserIdConcept) customerId!: UserId;
@field(Number) totalAmount!: number;}
// Create and serialize - can use either concept instances or primitivesconst order = new Order();order.orderId = new OrderIdConcept('order-456'); // or 'order-456'order.customerId = 'user-123'; // or new UserIdConcept('user-123')order.totalAmount = 99.99;
const json = JsonSerializer.serialize(order);console.log(json);// {"orderId":"order-456","customerId":"user-123","totalAmount":99.99}
// Deserialize back to typed instancesconst restored = JsonSerializer.deserialize(Order, json);console.log(restored.orderId instanceof OrderIdConcept); // ✅ trueconsole.log(restored.customerId instanceof UserIdConcept); // ✅ true🔑 Key Point: The
@fielddecorator always references the concept class (e.g.,UserIdConcept), while the TypeScript type annotation uses the exported union type (e.g.,UserId). The@fieldmetadata ensures proper deserialization to the concept class, while the union type allows flexible assignment.
Union Types for Flexible Assignment
Section titled “Union Types for Flexible Assignment”To make it convenient to work with ConceptAs types, the recommended pattern is to export the concept as a union type that includes both the concept class and the underlying primitive. This allows both ConceptAs instances and primitive values to be assigned to the same field, providing flexibility while maintaining type safety and proper serialization.
Recommended Export Pattern
Section titled “Recommended Export Pattern”Define your concept file with both the concept class and the union type export:
import { ConceptAs } from '@cratis/fundamentals';
class UserIdConcept extends ConceptAs<string> {}
export type UserId = UserIdConcept | string;import { ConceptAs } from '@cratis/fundamentals';
class OrderCountConcept extends ConceptAs<number> {}
export type OrderCount = OrderCountConcept | number;Benefits of this pattern:
- Convenience: Consumers can assign either the concept instance or the primitive value
- Readability: The exported type name (
UserId) is clean and matches domain language - Encapsulation: The concept class name (
UserIdConcept) is an implementation detail - Consistency: All concept files follow the same export pattern
Using the Exported Types
Section titled “Using the Exported Types”When declaring fields, import and use the exported union type. The @field decorator still references the concept class for proper serialization:
import { JsonSerializer, field } from '@cratis/fundamentals';import { UserId, UserIdConcept } from './UserId';import { OrderCount, OrderCountConcept } from './OrderCount';
class Order { // @field takes the class, type annotation uses the exported union type @field(UserIdConcept) userId!: UserId;
@field(OrderCountConcept) orderCount!: OrderCount;
@field(String) description!: string;}🔑 Key Point: The
@fielddecorator specifies the concept class (e.g.,UserIdConcept) for serialization metadata, while the TypeScript type annotation uses the exported union type (e.g.,UserId) for flexible assignment.
Assignment Flexibility
Section titled “Assignment Flexibility”With union types exported from the concept file, you can assign either concept instances or primitive values:
const order = new Order();
// Option 1: Assign concept instancesorder.userId = new UserIdConcept('user-123');order.orderCount = new OrderCountConcept(42);
// Option 2: Assign primitive values directlyorder.userId = 'user-456';order.orderCount = 99;
// Option 3: Mix both approachesorder.userId = new UserIdConcept('user-789');order.orderCount = 55; // primitiveSerialization Behavior
Section titled “Serialization Behavior”The JsonSerializer handles both ConceptAs instances and primitives correctly, thanks to the @field decorator metadata:
Serialization - Both ConceptAs instances and primitives serialize to the inner primitive value:
// With ConceptAs instancesconst order1 = new Order();order1.userId = new UserIdConcept('user-123');order1.orderCount = new OrderCountConcept(42);
const json1 = JsonSerializer.serialize(order1);// Result: {"userId":"user-123","orderCount":42}
// With primitivesconst order2 = new Order();order2.userId = 'user-456';order2.orderCount = 99;
const json2 = JsonSerializer.serialize(order2);// Result: {"userId":"user-456","orderCount":99}Deserialization - Always deserializes to ConceptAs instances based on the @field decorator:
const json = '{"userId":"user-123","orderCount":42}';const order = JsonSerializer.deserialize(Order, json);
console.log(order.userId instanceof UserIdConcept); // ✅ trueconsole.log(order.orderCount instanceof OrderCountConcept); // ✅ trueconsole.log((order.userId as UserIdConcept).value); // "user-123"console.log((order.orderCount as OrderCountConcept).value); // 42📝 Note: Regardless of whether you assigned a ConceptAs instance or a primitive during serialization, deserialization always creates ConceptAs instances based on the concept class specified in the
@fielddecorator.
Implementation Details
Section titled “Implementation Details”The JsonSerializer follows the C# pattern for handling ConceptAs types:
- Recognition: During serialization, the serializer detects ConceptAs instances
- Unwrapping: Extracts the inner value from the ConceptAs instance
- Recursive Serialization: Calls the serializer recursively on the inner value to handle complex types properly
This ensures that:
- Simple primitives (string, number, boolean) are serialized directly
- Complex inner types are properly serialized using their own serialization logic
- Union types work seamlessly without special handling
Best Practices for Union Types
Section titled “Best Practices for Union Types”✅ DO export union types from the concept file for convenience and consistency:
import { ConceptAs } from '@cratis/fundamentals';
class UserIdConcept extends ConceptAs<string> {}
export type UserId = UserIdConcept | string;✅ DO use union types in method signatures for flexible APIs:
import { UserId, UserIdConcept } from './UserId';import { OrderCount, OrderCountConcept } from './OrderCount';
class OrderService { // Flexible method signature accepts both concept instances and primitives createOrder(userId: UserId, count: OrderCount) { const order = new Order(); order.userId = userId; // Works with both types order.orderCount = count; return order; }}
// Can be called both waysservice.createOrder(new UserIdConcept('user-123'), new OrderCountConcept(42));service.createOrder('user-123', 42);✅ DO use type guards when you need to distinguish between ConceptAs and primitives:
import { UserId, UserIdConcept } from './UserId';
function processUserId(userId: UserId) { if (userId instanceof UserIdConcept) { // Handle ConceptAs instance console.log('UserId:', userId.value); } else { // Handle primitive string console.log('String:', userId); }}❌ DON’T forget the @field decorator - it’s required for proper deserialization:
// ❌ BAD: Missing @field decoratorclass Order { userId!: UserId; // Won't deserialize correctly!}
// ✅ GOOD: Has @field decorator with concept classclass Order { @field(UserIdConcept) userId!: UserId;}API Reference
Section titled “API Reference”Constructor
Section titled “Constructor”constructor(readonly value: T)Creates a new instance of the concept with the specified value.
Parameters:
value: T- The underlying value to wrap
Example:
class UserIdConcept extends ConceptAs<string> {}export type UserId = UserIdConcept | string;
const userId = new UserIdConcept('user-123');Properties
Section titled “Properties”value: T (readonly)
Section titled “value: T (readonly)”The underlying primitive value.
Example:
const userId = new UserIdConcept('user-123');console.log(userId.value); // "user-123"Methods
Section titled “Methods”valueOf(): T
Section titled “valueOf(): T”Returns the primitive value of the concept. This method is used by JavaScript when automatic type coercion is needed.
Returns: The underlying value
Example:
class OrderCountConcept extends ConceptAs<number> {}export type OrderCount = OrderCountConcept | number;
const count = new OrderCountConcept(42);console.log(count.valueOf()); // 42console.log(count + 10); // 52 (automatic coercion)toString(): string
Section titled “toString(): string”Returns the string representation of the concept.
Returns: String representation of the underlying value
Example:
const userId = new UserIdConcept('user-123');console.log(userId.toString()); // "user-123"
const count = new OrderCountConcept(42);console.log(count.toString()); // "42"Best Practices
Section titled “Best Practices”1. Use for Domain Identifiers
Section titled “1. Use for Domain Identifiers”Create ConceptAs types for all domain identifiers with the union type export pattern:
class UserIdConcept extends ConceptAs<string> {}export type UserId = UserIdConcept | string;
// ProductId.tsclass ProductIdConcept extends ConceptAs<string> {}export type ProductId = ProductIdConcept | string;
// OrderId.tsclass OrderIdConcept extends ConceptAs<string> {}export type OrderId = OrderIdConcept | string;
// CustomerId.tsclass CustomerIdConcept extends ConceptAs<string> {}export type CustomerId = CustomerIdConcept | string;2. Use for Measured Values
Section titled “2. Use for Measured Values”Wrap numeric values that represent specific measurements or counts:
class OrderCountConcept extends ConceptAs<number> {}export type OrderCount = OrderCountConcept | number;
// Price.tsclass PriceConcept extends ConceptAs<number> {}export type Price = PriceConcept | number;
// Quantity.tsclass QuantityConcept extends ConceptAs<number> {}export type Quantity = QuantityConcept | number;
// TemperatureInCelsius.tsclass TemperatureInCelsiusConcept extends ConceptAs<number> {}export type TemperatureInCelsius = TemperatureInCelsiusConcept | number;3. Use Union Types for Flexibility
Section titled “3. Use Union Types for Flexibility”Export union types from your concept files to allow both ConceptAs instances and primitives, providing flexibility while maintaining proper serialization:
import { ConceptAs } from '@cratis/fundamentals';
class UserIdConcept extends ConceptAs<string> {}export type UserId = UserIdConcept | string;import { field } from '@cratis/fundamentals';import { UserId, UserIdConcept } from './UserId';import { OrderCount, OrderCountConcept } from './OrderCount';
class Order { // Use the exported union type in the type annotation // Use the concept class in the @field decorator @field(UserIdConcept) userId!: UserId;
@field(OrderCountConcept) orderCount!: OrderCount;}
// Both work correctlyorder.userId = new UserIdConcept('user-123');order.userId = 'user-456';See the Union Types for Flexible Assignment section for complete details.
4. Don’t Add Extra Properties
Section titled “4. Don’t Add Extra Properties”⚠️ Important: ConceptAs types are meant to wrap a single primitive value only. Don’t add additional properties to ConceptAs subclasses, as the JsonSerializer assumes they only contain the
valueproperty.
// ❌ DON'T do thisclass UserIdConcept extends ConceptAs<string> { createdAt: Date; // This will break serialization}
// ✅ DO this instead - create a separate classclass User { @field(UserIdConcept) id!: UserId;
@field(Date) createdAt!: Date;}5. Keep Concepts Simple
Section titled “5. Keep Concepts Simple”ConceptAs is for wrapping primitives. For complex domain objects with multiple properties, create regular classes instead.
Comparison with C# ConceptAs
Section titled “Comparison with C# ConceptAs”The TypeScript implementation mirrors the C# version but with JavaScript idioms:
| Feature | C# | TypeScript |
|---|---|---|
| Base class | ConceptAs<T> record | ConceptAs<T> abstract class |
| Value property | Value property | value readonly property |
| Implicit conversion | implicit operator T | valueOf() method |
| Flexible assignment | Implicit operators | Union types (e.g., UserId | string) |
| String representation | ToString() override | toString() method |
| JSON serialization | ConceptAsJsonConverter | Built into JsonSerializer |
| Serialization pattern | Unwrap → Serialize recursively | Unwrap → Serialize recursively |
| Comparison operators | IComparable interface | Not implemented (use value directly) |
Key Difference: TypeScript union types (UserId | string) provide a more explicit and type-safe way to allow both ConceptAs instances and primitives compared to C#‘s implicit operators.
See Also
Section titled “See Also”- JsonSerializer - JSON serialization with ConceptAs support
- Field Decorator - Declaring fields for serialization
- C# Concepts - C# equivalent documentation