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:

  1. Field-level validationMessages (highest priority)
  2. Form-level defaultValidationMessages (fallback)
  3. Error message - The error's own message, as set by schema validators
  4. 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