Skip to content

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;

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.

Instead of passing around generic strings or numbers, you create strongly-typed domain concepts:

// Without ConceptAs - prone to errors
function getUser(id: string) { ... }
function getOrder(id: string) { ... }
// With ConceptAs - compile-time safety
class 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 expected

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 meaning
class 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) { ... }

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;
}
// Serialization
const 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"}
// Deserialization
const deserialized = JsonSerializer.deserialize(User, json);
console.log(deserialized.id instanceof UserIdConcept); // ✅ true
console.log((deserialized.id as UserIdConcept).value); // "user-123"

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 @field decorator metadata ensures correct serialization/deserialization

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 class
const userId = new UserIdConcept('user-123');
const orderCount = new OrderCountConcept(42);
const isActive = new IsActiveConcept(true);
// Access the underlying value
console.log(userId.value); // "user-123"
console.log(orderCount.value); // 42
// Get primitive value for operations
console.log(userId.valueOf()); // "user-123"
// String representation
console.log(userId.toString()); // "user-123"
console.log(orderCount.toString()); // "42"
// With union types, you can also assign primitives directly
let flexibleUserId: UserId = 'user-456'; // Works!
flexibleUserId = new UserIdConcept('user-789'); // Also works!

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 exports
class 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 primitives
const 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 instances
const restored = JsonSerializer.deserialize(Order, json);
console.log(restored.orderId instanceof OrderIdConcept); // ✅ true
console.log(restored.customerId instanceof UserIdConcept); // ✅ true

🔑 Key Point: The @field decorator always references the concept class (e.g., UserIdConcept), while the TypeScript type annotation uses the exported union type (e.g., UserId). The @field metadata ensures proper deserialization to the concept class, while the union type allows 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.

Define your concept file with both the concept class and the union type export:

UserId.ts
import { ConceptAs } from '@cratis/fundamentals';
class UserIdConcept extends ConceptAs<string> {}
export type UserId = UserIdConcept | string;
OrderCount.ts
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

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 @field decorator 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.

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 instances
order.userId = new UserIdConcept('user-123');
order.orderCount = new OrderCountConcept(42);
// Option 2: Assign primitive values directly
order.userId = 'user-456';
order.orderCount = 99;
// Option 3: Mix both approaches
order.userId = new UserIdConcept('user-789');
order.orderCount = 55; // primitive

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 instances
const 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 primitives
const 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); // ✅ true
console.log(order.orderCount instanceof OrderCountConcept); // ✅ true
console.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 @field decorator.

The JsonSerializer follows the C# pattern for handling ConceptAs types:

  1. Recognition: During serialization, the serializer detects ConceptAs instances
  2. Unwrapping: Extracts the inner value from the ConceptAs instance
  3. 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

✅ DO export union types from the concept file for convenience and consistency:

UserId.ts
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 ways
service.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 decorator
class Order {
userId!: UserId; // Won't deserialize correctly!
}
// ✅ GOOD: Has @field decorator with concept class
class Order {
@field(UserIdConcept)
userId!: UserId;
}
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');

The underlying primitive value.

Example:

const userId = new UserIdConcept('user-123');
console.log(userId.value); // "user-123"

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()); // 42
console.log(count + 10); // 52 (automatic coercion)

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"

Create ConceptAs types for all domain identifiers with the union type export pattern:

UserId.ts
class UserIdConcept extends ConceptAs<string> {}
export type UserId = UserIdConcept | string;
// ProductId.ts
class ProductIdConcept extends ConceptAs<string> {}
export type ProductId = ProductIdConcept | string;
// OrderId.ts
class OrderIdConcept extends ConceptAs<string> {}
export type OrderId = OrderIdConcept | string;
// CustomerId.ts
class CustomerIdConcept extends ConceptAs<string> {}
export type CustomerId = CustomerIdConcept | string;

Wrap numeric values that represent specific measurements or counts:

OrderCount.ts
class OrderCountConcept extends ConceptAs<number> {}
export type OrderCount = OrderCountConcept | number;
// Price.ts
class PriceConcept extends ConceptAs<number> {}
export type Price = PriceConcept | number;
// Quantity.ts
class QuantityConcept extends ConceptAs<number> {}
export type Quantity = QuantityConcept | number;
// TemperatureInCelsius.ts
class TemperatureInCelsiusConcept extends ConceptAs<number> {}
export type TemperatureInCelsius = TemperatureInCelsiusConcept | number;

Export union types from your concept files to allow both ConceptAs instances and primitives, providing flexibility while maintaining proper serialization:

UserId.ts
import { ConceptAs } from '@cratis/fundamentals';
class UserIdConcept extends ConceptAs<string> {}
export type UserId = UserIdConcept | string;
Order.ts
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 correctly
order.userId = new UserIdConcept('user-123');
order.userId = 'user-456';

See the Union Types for Flexible Assignment section for complete details.

⚠️ 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 value property.

// ❌ DON'T do this
class UserIdConcept extends ConceptAs<string> {
createdAt: Date; // This will break serialization
}
// ✅ DO this instead - create a separate class
class User {
@field(UserIdConcept)
id!: UserId;
@field(Date)
createdAt!: Date;
}

ConceptAs is for wrapping primitives. For complex domain objects with multiple properties, create regular classes instead.

The TypeScript implementation mirrors the C# version but with JavaScript idioms:

FeatureC#TypeScript
Base classConceptAs<T> recordConceptAs<T> abstract class
Value propertyValue propertyvalue readonly property
Implicit conversionimplicit operator TvalueOf() method
Flexible assignmentImplicit operatorsUnion types (e.g., UserId | string)
String representationToString() overridetoString() method
JSON serializationConceptAsJsonConverterBuilt into JsonSerializer
Serialization patternUnwrap → Serialize recursivelyUnwrap → Serialize recursively
Comparison operatorsIComparable interfaceNot 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.