Master Aurelia's value converters for powerful data transformation. Learn formatting, localization, custom converters, performance optimization, and real-world patterns.
Value converters are a powerful feature in Aurelia 2 that transform data as it flows between your view model and view. They enable clean separation of concerns by moving data formatting logic out of your view models while keeping templates readable and maintainable.
Overview
Value converters excel at:
Data formatting - dates, numbers, currencies, text transformations
Localization - dynamic content based on user locale
Display logic - conditional formatting without cluttering view models
Two-way transformations - handling both display and input conversion
Reactive updates - automatic re-evaluation on global state changes
Pure functions - predictable, testable transformations
Reusable - use the same converter across multiple components
Composable - chain multiple converters for complex transformations
Framework integration - seamless integration with Aurelia's binding system
TypeScript support - full type safety and intellisense
Data Flow
Converters work in two directions:
toView: Prepares model data for display.
fromView: Adjusts view data before updating the model (useful with two-way binding).
Both methods receive the primary value as the first argument, with any extra arguments used as configuration.
Example Methods
Basic Usage
Template Syntax
Use the pipe symbol (|) to apply a converter in templates:
Simple Converter Example
Usage in template:
Parameter Passing
Converters accept parameters using colons (:) for configuration:
Static Parameters
Bound Parameters
Object Parameters
Chaining Converters
Chain multiple converters for complex transformations:
Chain execution order: Left to right, where each converter receives the output of the previous one.
Advanced Template Patterns
Conditional Formatting
Dynamic Parameter Selection
Nested Object Access
Receiving the Caller Context
By default, value converters receive only the value to transform and any configuration parameters. In some advanced scenarios, you may need to know more about the binding or calling context that invoked the converter—for example, to adjust the transformation based on the host element, attributes, or other binding-specific state.
Aurelia 2 provides an opt-in mechanism to receive the binding instance itself as an additional parameter. To enable this feature:
Add withContext: true to your value converter class:
Then use your converter in templates as usual:
At runtime, Aurelia will detect withContext: true in the value converter and pass the binding instance as the second parameter. Depending on how the converter is used:
Property Binding (foo.bind or attr.bind): the caller is a PropertyBinding instance
Interpolation (${ } with converters): the caller is an InterpolationPartBinding instance
Other Bindings: the caller corresponds to the specific binding type in use
Common Use Cases
Logging or debugging which binding invoked the converter
Applying different formatting based on binding context
Accessing binding metadata or context not available through standard converter parameters
Use this feature sparingly, only when you truly need insights into the calling context. For most formatting scenarios, simple converter parameters and camelCase converter names are sufficient.
Accessing the View Model and Binding Context
Once withContext: true is enabled, your converter receives a caller parameter with direct access to the view model and binding information:
Caller Context Properties
caller.source: The view model instance of the component where the converter is used
This is the actual component class instance with all its properties and methods
Allows converters to access component state, computed properties, and methods
Always available when converter is used within a component
caller.binding: The binding instance that invoked the converter
Contains binding-specific information and metadata
Useful for debugging or advanced binding manipulation
Type varies: PropertyBinding, InterpolationPartBinding, etc.
Real-World Example: User Permission Converter
Usage in template:
Registration Patterns
Aurelia 2 provides flexible registration patterns for different use cases and architectural preferences.
1. Decorator Registration (Recommended)
The most common and straightforward approach:
2. Configuration Object Registration
For advanced options including aliases:
Usage with aliases:
3. Static Definition
Using the static $au property (alternative registration approach):
4. Manual Registration
For dynamic or runtime registration scenarios:
5. Local vs Global Registration
Global Registration (Application-wide)
Local Registration (Component-specific)
Scoped Registration (Feature Module)
6. Conditional Registration
Register converters based on environment or feature flags:
Best Practices for Registration
Use decorators for most cases - Simple and straightforward
Group related converters - Organize by feature or domain
Consider lazy loading - Register heavy converters only when needed
Document aliases - Make alternative names clear to team members
Avoid global pollution - Use local registration for component-specific logic
Creating Custom Value Converters
Custom value converters are classes that implement transformation logic. They provide a clean way to handle data formatting throughout your application.
Basic Structure
TypeScript Best Practices
Strong Typing
Generic Converters
Bidirectional Converters (Two-Way Binding)
Perfect for form inputs that need both display formatting and input parsing:
Phone Number Formatter
Usage with two-way binding:
Credit Card Formatter
Error Handling and Validation
Performance Optimization
Memoized Converter
Utility Converters
Null-Safe Converter
Debug Converter
Usage:
Signals-Based Reactivity
Value converters can automatically re-evaluate when specific signals are dispatched, perfect for locale changes, theme updates, or global state changes.
To trigger re-evaluation from anywhere in your app:
Now all localeDate converters automatically update when the locale changes, without needing to manually refresh bindings.
Built-in Signal-Aware Converters
Aurelia 2 includes several built-in converters that leverage signals:
Built-in Value Converters
Aurelia 2 includes several built-in converters ready for use:
Sanitize Converter
Aurelia 2 includes a sanitize converter, but it requires you to provide your own sanitizer implementation:
Then you can use the sanitize converter:
Note: The built-in sanitize converter throws an error by default. You must provide your own ISanitizer implementation for it to work.
// toView: from model to view
toView(value, ...args) { /* transform value for display */ }
// fromView: from view to model
fromView(value, ...args) { /* transform value for the model */ }
import { valueConverter } from 'aurelia';
@valueConverter({ name: 'myConverter' })
export class MyConverter {
public readonly withContext = true;
public toView(value, caller, ...args) {
// `caller` is an object with:
// - `source`: The closest custom element view-model, if any.
// - `binding`: The binding instance (e.g., PropertyBinding, InterpolationPartBinding).
console.log('Converter called by binding:', caller.binding);
console.log('Source/Component VM:', caller.source);
// Use binding-specific state if needed, then return transformed value
return /* your transformation logic */;
}
public fromView?(value, caller, ...args) {
// For two-way binding scenarios, you can similarly access the caller properties
return /* reverse transformation logic */;
}
}
import { valueConverter, type ICallerContext } from '@aurelia/runtime-html';
@valueConverter('vmAware')
export class ViewModelAwareConverter {
readonly withContext = true;
toView(value: unknown, caller: ICallerContext): string {
// Direct access to the view model instance
const viewModel = caller.source as MyComponent;
// Access view model properties and methods
if (viewModel.isAdmin) {
return `Admin: ${value}`;
}
// Use view model data for transformation
return `${value} (${viewModel.userName})`;
}
}
import { valueConverter, type ICallerContext } from '@aurelia/runtime-html';
interface UserComponent {
currentUser: { role: string; permissions: string[] };
isOwner(itemId: string): boolean;
}
@valueConverter('userPermission')
export class UserPermissionConverter {
readonly withContext = true;
toView(
action: string,
caller: ICallerContext,
requiredPermission?: string
): boolean {
const component = caller.source as UserComponent;
// Access view model properties
const user = component.currentUser;
if (!user) return false;
// Use view model methods
if (action === 'delete' && component.isOwner) {
return component.isOwner(requiredPermission || '');
}
// Check permissions
return user.permissions.includes(requiredPermission || action);
}
}
<button if.bind="'edit' | userPermission:'edit-posts'">
Edit Post
</button>
<button if.bind="'delete' | userPermission:post.id">
Delete Post
</button>
import { ValueConverter, IContainer } from 'aurelia';
// Method 1: ValueConverter.define()
const DynamicConverter = ValueConverter.define('dynamic', class {
toView(value: unknown): string {
return `[Dynamic: ${value}]`;
}
});
// Method 2: Container registration
export class RuntimeConverter {
toView(value: unknown): string {
return String(value);
}
}
// Register manually in main.ts or configure function
container.register(ValueConverter.define('runtime', RuntimeConverter));
// Available throughout the entire application
@valueConverter('global')
export class GlobalConverter {
toView(value: string): string {
return value.toUpperCase();
}
}
import { LocalConverter } from './local-converter';
@customElement({
name: 'my-element',
template: '<span>${data | localConverter}</span>',
dependencies: [LocalConverter] // Only available in this component tree
})
export class MyElement {
data = 'hello world';
}
// feature-module.ts
import { IContainer } from 'aurelia';
export function configure(container: IContainer) {
container.register(
ValueConverter.define('featureSpecific', FeatureConverter)
);
}
// main.ts
import { IContainer } from 'aurelia';
export function configure(container: IContainer) {
// Production vs Development converters
if (process.env.NODE_ENV === 'development') {
container.register(DebugConverter);
}
// Feature flag based registration
if (featureFlags.enableAdvancedFormatting) {
container.register(AdvancedFormattingConverter);
}
}
import { valueConverter } from 'aurelia';
@valueConverter('converterName')
export class ConverterNameValueConverter {
// Required: transform data for display
toView(value: InputType, ...args: unknown[]): OutputType {
// Transform value for display
return transformedValue;
}
// Optional: transform data from user input back to model
fromView?(value: InputType, ...args: unknown[]): OutputType {
// Transform user input back to model format
return transformedValue;
}
// Optional: signals for automatic re-evaluation
readonly signals?: string[] = ['signal-name'];
// Optional: enables binding context access
readonly withContext?: boolean = false;
}
@valueConverter('phoneNumber')
export class PhoneNumberConverter {
toView(value: string | null | undefined): string {
if (!value) return '';
// Remove all non-digits
const digits = value.replace(/\D/g, '');
// Format as (XXX) XXX-XXXX for US numbers
if (digits.length >= 10) {
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6, 10)}`;
}
return digits;
}
fromView(value: string): string {
// Store only digits in the model
return value.replace(/\D/g, '');
}
}
import { valueConverter, ISignaler, resolve } from 'aurelia';
@valueConverter('localeDate')
export class LocaleDateConverter {
private signaler = resolve(ISignaler);
public readonly signals = ['locale-changed', 'timezone-changed'];
toView(value: string, locale?: string) {
const currentLocale = locale || this.getCurrentLocale();
return new Intl.DateTimeFormat(currentLocale, {
month: 'long',
day: 'numeric',
year: 'numeric'
}).format(new Date(value));
}
private getCurrentLocale() {
// Get current locale from your app state
return 'en-US';
}
}
import { resolve, ISignaler } from 'aurelia';
export class LocaleService {
private signaler = resolve(ISignaler);
changeLocale(newLocale: string) {
// Update your locale
this.signaler.dispatchSignal('locale-changed');
}
}
<!-- Automatically updates when locale changes -->
<p>${message | t}</p> <!-- Translation -->
<p>${date | df}</p> <!-- Date format -->
<p>${number | nf}</p> <!-- Number format -->
<p>${date | rt}</p> <!-- Relative time -->
import { ISanitizer } from 'aurelia';
// You must register your own sanitizer implementation
export class MyHtmlSanitizer implements ISanitizer {
sanitize(input: string): string {
// Implement your sanitization logic
// You might use a library like DOMPurify here
return input; // This is just an example - implement proper sanitization!
}
}
// Register it in your main configuration
container.register(singletonRegistration(ISanitizer, MyHtmlSanitizer));
// ✅ Good - focused on one transformation
@valueConverter('capitalize')
export class CapitalizeConverter {
toView(text: string): string {
return text.charAt(0).toUpperCase() + text.slice(1).toLowerCase();
}
}
// ❌ Bad - doing too many things
@valueConverter('formatEverything')
export class FormatEverythingConverter {
toView(value: unknown, type: string): string {
// This converter tries to handle too many different cases
}
}
// ✅ Good - no side effects
@valueConverter('multiply')
export class MultiplyConverter {
toView(value: number, factor: number): number {
return value * factor;
}
}
// ❌ Bad - side effects
@valueConverter('logAndMultiply')
export class LogAndMultiplyConverter {
toView(value: number, factor: number): number {
console.log('Processing:', value); // Side effect
this.updateGlobalCounter(); // Side effect
return value * factor;
}
}
describe('CurrencyConverter', () => {
let converter: CurrencyConverter;
beforeEach(() => {
converter = new CurrencyConverter();
});
it('should format USD currency correctly', () => {
const result = converter.toView(1234.56, { locale: 'en-US', currency: 'USD' });
expect(result).toBe('$1,234.56');
});
it('should handle null values gracefully', () => {
const result = converter.toView(null);
expect(result).toBe('');
});
it('should parse formatted currency back to number', () => {
const result = converter.fromView('$1,234.56');
expect(result).toBe(1234.56);
});
});
<import from="./my-converter"></import>
@valueConverter('myConverter') // Must match template usage
export class MyConverter { }
// In main.ts
import { MyConverter } from './my-converter';
Aurelia.register(MyConverter).app(MyApp).start();
private cache = new Map();
toView(value: string): string {
if (this.cache.has(value)) return this.cache.get(value);
// ... expensive operation
}
readonly signals = ['data-changed'];
// Update only when signal is dispatched
<!-- ❌ Bad - converter called for every item -->
<div repeat.for="item of items">
${expensiveData | expensiveConverter}
</div>
<!-- ✅ Good - converter called once -->
<div repeat.for="item of items">
${item.name}
</div>
<div>${expensiveData | expensiveConverter}</div>
readonly withContext = true; // Required property
toView(value: unknown, caller: ICallerContext, ...args: unknown[]): unknown {
// caller is always second parameter when withContext = true
}
readonly signals = ['my-signal']; // Array of signal names
import { resolve } from '@aurelia/kernel';
import { ISignaler } from '@aurelia/runtime-html';
private signaler = resolve(ISignaler);
updateData(): void {
// Update data first
this.signaler.dispatchSignal('my-signal');
}