Dynamic Forms supports internationalization through Angular's reactive primitives: Observables and Signals. It's framework-agnostic - use any translation library that provides these types.
How i18n Works
Dynamic Forms uses the DynamicText type for all text properties:
type DynamicText = string | Observable<string> | Signal<string>;Any property that accepts text (label, placeholder, validationMessages, and UI-integration specific props like hint) accepts:
- Static strings
- Observables (from translation libraries like Transloco, ngx-translate, etc.)
- Signals
Basic Example
With any translation service that returns Observables:
import { Component, inject } from '@angular/core';
@Component({...})
export class MyFormComponent {
translationService = inject(YourTranslationService);
config = {
fields: [
{
key: 'firstName',
type: 'input',
label: this.translationService.translate('form.firstName'), // Observable<string>
value: '',
},
{
key: 'email',
type: 'input',
label: this.translationService.translate('form.email'),
email: true,
validationMessages: {
required: this.translationService.translate('validation.required'),
email: this.translationService.translate('validation.email'),
},
value: '',
},
],
};
}The form automatically updates when translations change.
Default Validation Messages
Define common validation messages once at the form level using defaultValidationMessages. These act as fallback messages when fields don't have their own custom validationMessages:
import { Component, inject } from '@angular/core';
@Component({...})
export class MyFormComponent {
translationService = inject(YourTranslationService);
config = {
// Define default messages for all fields
defaultValidationMessages: {
required: this.translationService.translate('validation.required'),
email: this.translationService.translate('validation.email'),
minLength: this.translationService.translate('validation.minLength'),
maxLength: this.translationService.translate('validation.maxLength'),
},
fields: [
{
key: 'email',
type: 'input',
label: this.translationService.translate('form.email'),
email: true,
required: true,
// Uses defaultValidationMessages for required and email errors
},
{
key: 'password',
type: 'input',
label: this.translationService.translate('form.password'),
required: true,
minLength: 8,
// Override default for this field only
validationMessages: {
required: this.translationService.translate('validation.password.required'),
minLength: this.translationService.translate('validation.password.minLength'),
},
},
{
key: 'username',
type: 'input',
label: this.translationService.translate('form.username'),
required: true,
minLength: 3,
// Uses default for both required and minLength
},
],
};
}The message resolution priority is:
- Field-level
validationMessages(highest priority) - Form-level
defaultValidationMessages(fallback) - Error message - The error's own
message, as set by schema validators - No message - If all three are absent, the error is not displayed and a warning is logged to the console
This approach is especially useful when you have many fields with the same validation rules - define the translations once instead of repeating them for each field.
Example with Transloco
Here's a complete example using @jsverse/transloco:
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
import { provideTransloco } from '@jsverse/transloco';
import { provideDynamicForm } from '@ng-forge/dynamic-forms';
import { withMaterialFields } from '@ng-forge/dynamic-forms-material';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(),
provideTransloco({
config: {
availableLangs: ['en', 'es'],
defaultLang: 'en',
reRenderOnLangChange: true,
},
loader: TranslocoHttpLoader,
}),
provideDynamicForm(...withMaterialFields()),
],
};import { Component, inject } from '@angular/core';
import { TranslocoService } from '@jsverse/transloco';
import { DynamicForm } from '@ng-forge/dynamic-forms';
@Component({
selector: 'app-my-form',
imports: [DynamicForm],
template: ` <form [dynamic-form]="formConfig"></form> `,
})
export class MyFormComponent {
transloco = inject(TranslocoService);
formConfig = {
// Define default validation messages for all fields
defaultValidationMessages: {
required: this.transloco.selectTranslate('validation.required'),
email: this.transloco.selectTranslate('validation.email'),
},
fields: [
{
key: 'username',
type: 'input',
label: this.transloco.selectTranslate('form.username'),
value: '',
required: true,
// Uses defaultValidationMessages for required
},
{
key: 'email',
type: 'input',
label: this.transloco.selectTranslate('form.email'),
value: '',
required: true,
email: true,
// Uses defaultValidationMessages for required and email
},
],
};
// Optional: Switch languages
changeLanguage(lang: string) {
this.transloco.setActiveLang(lang);
// Form automatically updates due to reRenderOnLangChange: true
}
}Passing Error Params to Translations
Validation errors carry parameters such as maxLength, min, or max. A plain Observable message is resolved before the error exists, so the translation library never sees those values. To hand them to the i18n layer directly, use a message function: any validationMessages or defaultValidationMessages value can be a function that receives the validation error and returns a DynamicText.
import { Component, inject } from '@angular/core';
import { TranslocoService } from '@jsverse/transloco';
import { FormConfig, ValidationError } from '@ng-forge/dynamic-forms';
@Component({...})
export class MyFormComponent {
transloco = inject(TranslocoService);
formConfig: FormConfig = {
defaultValidationMessages: {
required: this.transloco.selectTranslate('validation.required'),
// Function message: receives the error, passes its params to Transloco
maxLength: (error: ValidationError) =>
this.transloco.selectTranslate('validation.maxLength', {
requiredLength: 'maxLength' in error ? error.maxLength : undefined,
}),
min: (error: ValidationError) =>
this.transloco.selectTranslate('validation.min', {
min: 'min' in error ? error.min : undefined,
}),
},
fields: [
{
key: 'username',
type: 'input',
label: this.transloco.selectTranslate('form.username'),
value: '',
maxLength: 20,
},
],
};
}With a translation file entry like "maxLength": "Must be at most {{requiredLength}} characters", Transloco interpolates the value itself.
The function can return any DynamicText: a string, an Observable<string>, or a Signal<string>. Message functions work for both field-level validationMessages and form-level defaultValidationMessages, and field-level functions still take precedence over form-level ones. A function that synchronously reads a signal and returns a plain string captures that value once and loses reactivity, so return the Signal or Observable itself instead.
The older round-trip workaround also keeps working: if your translation returns a string that still contains {{requiredLength}} (or another error param), Dynamic Forms interpolates it from the error as a final pass. Note that some libraries, including Transloco, strip unknown {{...}} placeholders during their own interpolation, which is exactly what function messages avoid.
Function messages are code, not data. If you serialize your form configs to JSON, keep using string templates with {{param}} placeholders.
Example with Signals
Use Angular signals for translations by wrapping the config in computed():
import { Component, signal, computed } from '@angular/core';
@Component({...})
export class MyFormComponent {
currentLang = signal<'en' | 'es'>('en');
translations = computed(() => ({
en: {
username: 'Username',
email: 'Email',
required: 'This field is required',
email_format: 'Please enter a valid email address',
},
es: {
username: 'Nombre de usuario',
email: 'Correo electrónico',
required: 'Este campo es obligatorio',
email_format: 'Por favor ingrese una dirección de correo válida',
},
}[this.currentLang()]));
// Wrap entire config in computed() - rebuilds when language changes
formConfig = computed(() => ({
defaultValidationMessages: {
required: this.translations().required,
email: this.translations().email_format,
},
fields: [
{
key: 'username',
type: 'input',
label: this.translations().username,
value: '',
required: true,
},
{
key: 'email',
type: 'input',
label: this.translations().email,
value: '',
required: true,
email: true,
},
],
}));
switchLanguage(lang: 'en' | 'es') {
this.currentLang.set(lang);
// Config recomputes → form updates with new translations
}
}How it works: The computed() wrapper tracks the currentLang signal dependency. When currentLang changes, the entire config is recomputed with new translation values, and the form updates automatically.
Translated Select Options
Each option's label supports DynamicText. The options array itself is static; make the individual labels reactive:
{
key: 'country',
type: 'select',
label: translationService.translate('form.country'),
value: '',
options: [
{ value: 'us', label: translationService.translate('countries.us') },
{ value: 'es', label: translationService.translate('countries.es') },
],
}Other Translation Libraries
Dynamic Forms works with any library that provides Observables or Signals:
ngx-translate:
label: this.translate.get('form.username'); // Observable<string>Custom service:
label: this.myTranslationService.translate('key'); // Observable<string> or Signal<string>The key is that your translation method returns Observable<string> or Signal<string>.
Next Steps
- Form Submission: Configure async submission with loading states
- Conditional Logic: Show, hide, and disable fields based on form state
- Value Derivation: Automatically compute field values from other fields