Flutter Localization with Freezed: Type-Safe Translations with Code Generation
Managing translations in large Flutter applications can become error-prone. Typos in translation keys, missing translations, and refactoring nightmares are common issues. In this guide, you'll learn how to combine Flutter's localization system with Freezed for type-safe, code-generated translation models.
Why Use Freezed for Localization?
Freezed is a code generation package that creates immutable data classes with copyWith, equality, and serialization support. When applied to localization, it provides:
- Compile-time safety: Catch translation key errors before runtime
- IDE autocompletion: No more guessing translation keys
- Refactoring support: Rename keys across your entire codebase
- Type-safe parameters: Ensure placeholders have correct types
- Immutable translation models: Predictable state management
Project Setup
First, add the required dependencies:
# pubspec.yaml
dependencies:
flutter:
sdk: flutter
flutter_localizations:
sdk: flutter
freezed_annotation: ^2.4.1
json_annotation: ^4.8.1
dev_dependencies:
build_runner: ^2.4.8
freezed: ^2.4.7
json_serializable: ^6.7.1
Creating Type-Safe Translation Models
Step 1: Define Your Translation Model
Create a Freezed model that represents your translation structure:
// lib/l10n/translations.dart
import 'package:freezed_annotation/freezed_annotation.dart';
part 'translations.freezed.dart';
part 'translations.g.dart';
@freezed
class AppTranslations with _$AppTranslations {
const factory AppTranslations({
required CommonTranslations common,
required HomeTranslations home,
required AuthTranslations auth,
required SettingsTranslations settings,
}) = _AppTranslations;
factory AppTranslations.fromJson(Map<String, dynamic> json) =>
_$AppTranslationsFromJson(json);
}
@freezed
class CommonTranslations with _$CommonTranslations {
const factory CommonTranslations({
required String appName,
required String loading,
required String error,
required String retry,
required String cancel,
required String save,
required String delete,
required String confirm,
}) = _CommonTranslations;
factory CommonTranslations.fromJson(Map<String, dynamic> json) =>
_$CommonTranslationsFromJson(json);
}
@freezed
class HomeTranslations with _$HomeTranslations {
const factory HomeTranslations({
required String title,
required String welcome,
required String subtitle,
required String getStarted,
}) = _HomeTranslations;
factory HomeTranslations.fromJson(Map<String, dynamic> json) =>
_$HomeTranslationsFromJson(json);
}
@freezed
class AuthTranslations with _$AuthTranslations {
const factory AuthTranslations({
required String login,
required String logout,
required String email,
required String password,
required String forgotPassword,
required String createAccount,
required String loginError,
}) = _AuthTranslations;
factory AuthTranslations.fromJson(Map<String, dynamic> json) =>
_$AuthTranslationsFromJson(json);
}
@freezed
class SettingsTranslations with _$SettingsTranslations {
const factory SettingsTranslations({
required String title,
required String language,
required String theme,
required String notifications,
required String privacy,
required String about,
}) = _SettingsTranslations;
factory SettingsTranslations.fromJson(Map<String, dynamic> json) =>
_$SettingsTranslationsFromJson(json);
}
Step 2: Create JSON Translation Files
// assets/translations/en.json
{
"common": {
"appName": "My App",
"loading": "Loading...",
"error": "An error occurred",
"retry": "Retry",
"cancel": "Cancel",
"save": "Save",
"delete": "Delete",
"confirm": "Confirm"
},
"home": {
"title": "Home",
"welcome": "Welcome back!",
"subtitle": "What would you like to do today?",
"getStarted": "Get Started"
},
"auth": {
"login": "Login",
"logout": "Logout",
"email": "Email",
"password": "Password",
"forgotPassword": "Forgot password?",
"createAccount": "Create account",
"loginError": "Invalid email or password"
},
"settings": {
"title": "Settings",
"language": "Language",
"theme": "Theme",
"notifications": "Notifications",
"privacy": "Privacy",
"about": "About"
}
}
// assets/translations/es.json
{
"common": {
"appName": "Mi App",
"loading": "Cargando...",
"error": "Ocurrio un error",
"retry": "Reintentar",
"cancel": "Cancelar",
"save": "Guardar",
"delete": "Eliminar",
"confirm": "Confirmar"
},
"home": {
"title": "Inicio",
"welcome": "Bienvenido de nuevo!",
"subtitle": "Que te gustaria hacer hoy?",
"getStarted": "Comenzar"
},
"auth": {
"login": "Iniciar sesion",
"logout": "Cerrar sesion",
"email": "Correo electronico",
"password": "Contrasena",
"forgotPassword": "Olvidaste tu contrasena?",
"createAccount": "Crear cuenta",
"loginError": "Correo o contrasena invalidos"
},
"settings": {
"title": "Configuracion",
"language": "Idioma",
"theme": "Tema",
"notifications": "Notificaciones",
"privacy": "Privacidad",
"about": "Acerca de"
}
}
Step 3: Run Code Generation
flutter pub run build_runner build --delete-conflicting-outputs
Building the Translation Service
Create a service that loads and provides translations:
// lib/l10n/translation_service.dart
import 'dart:convert';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'translations.dart';
class TranslationService {
static final TranslationService _instance = TranslationService._internal();
factory TranslationService() => _instance;
TranslationService._internal();
AppTranslations? _translations;
Locale _currentLocale = const Locale('en');
AppTranslations get translations {
if (_translations == null) {
throw Exception('Translations not loaded. Call load() first.');
}
return _translations!;
}
Locale get currentLocale => _currentLocale;
static const supportedLocales = [
Locale('en'),
Locale('es'),
Locale('fr'),
Locale('de'),
];
Future<void> load(Locale locale) async {
_currentLocale = locale;
final jsonString = await rootBundle.loadString(
'assets/translations/${locale.languageCode}.json',
);
final jsonMap = json.decode(jsonString) as Map<String, dynamic>;
_translations = AppTranslations.fromJson(jsonMap);
}
Future<void> changeLocale(Locale newLocale) async {
if (newLocale != _currentLocale) {
await load(newLocale);
}
}
}
Creating a Localization Delegate
// lib/l10n/app_localizations_delegate.dart
import 'package:flutter/widgets.dart';
import 'translation_service.dart';
import 'translations.dart';
class AppLocalizations {
final AppTranslations translations;
AppLocalizations(this.translations);
static AppLocalizations of(BuildContext context) {
return Localizations.of<AppLocalizations>(context, AppLocalizations)!;
}
// Convenience getters
CommonTranslations get common => translations.common;
HomeTranslations get home => translations.home;
AuthTranslations get auth => translations.auth;
SettingsTranslations get settings => translations.settings;
}
class AppLocalizationsDelegate extends LocalizationsDelegate<AppLocalizations> {
final TranslationService translationService;
const AppLocalizationsDelegate(this.translationService);
@override
bool isSupported(Locale locale) {
return TranslationService.supportedLocales
.map((l) => l.languageCode)
.contains(locale.languageCode);
}
@override
Future<AppLocalizations> load(Locale locale) async {
await translationService.load(locale);
return AppLocalizations(translationService.translations);
}
@override
bool shouldReload(AppLocalizationsDelegate old) => false;
}
Integrating with Your App
// lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'l10n/app_localizations_delegate.dart';
import 'l10n/translation_service.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
final TranslationService _translationService = TranslationService();
Locale _locale = const Locale('en');
void _changeLocale(Locale newLocale) {
setState(() {
_locale = newLocale;
});
}
@override
Widget build(BuildContext context) {
return MaterialApp(
locale: _locale,
supportedLocales: TranslationService.supportedLocales,
localizationsDelegates: [
AppLocalizationsDelegate(_translationService),
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
home: HomePage(onLocaleChange: _changeLocale),
);
}
}
Using Translations in Widgets
Now you get full type safety and autocompletion:
// lib/screens/home_page.dart
import 'package:flutter/material.dart';
import '../l10n/app_localizations_delegate.dart';
class HomePage extends StatelessWidget {
final Function(Locale) onLocaleChange;
const HomePage({super.key, required this.onLocaleChange});
@override
Widget build(BuildContext context) {
// Type-safe access to translations
final l10n = AppLocalizations.of(context);
return Scaffold(
appBar: AppBar(
// IDE autocompletion works here!
title: Text(l10n.home.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
l10n.home.welcome,
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 8),
Text(l10n.home.subtitle),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () {},
child: Text(l10n.home.getStarted),
),
const SizedBox(height: 48),
// Language switcher
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_LanguageButton(
label: 'English',
locale: const Locale('en'),
onTap: onLocaleChange,
),
_LanguageButton(
label: 'Spanish',
locale: const Locale('es'),
onTap: onLocaleChange,
),
],
),
],
),
),
);
}
}
class _LanguageButton extends StatelessWidget {
final String label;
final Locale locale;
final Function(Locale) onTap;
const _LanguageButton({
required this.label,
required this.locale,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: ElevatedButton(
onPressed: () => onTap(locale),
child: Text(label),
),
);
}
}
Handling Parameterized Translations
For translations with dynamic values, add methods to your Freezed models:
@freezed
class HomeTranslations with _$HomeTranslations {
const HomeTranslations._(); // Enable custom methods
const factory HomeTranslations({
required String title,
required String welcome,
required String subtitle,
required String getStarted,
required String itemCount,
required String greeting,
}) = _HomeTranslations;
factory HomeTranslations.fromJson(Map<String, dynamic> json) =>
_$HomeTranslationsFromJson(json);
// Custom method for parameterized translation
String getItemCountText(int count) {
return itemCount.replaceAll('{count}', count.toString());
}
String getGreetingText(String name) {
return greeting.replaceAll('{name}', name);
}
}
With JSON:
{
"home": {
"title": "Home",
"welcome": "Welcome back!",
"subtitle": "What would you like to do today?",
"getStarted": "Get Started",
"itemCount": "You have {count} items",
"greeting": "Hello, {name}!"
}
}
Usage:
Text(l10n.home.getItemCountText(5)); // "You have 5 items"
Text(l10n.home.getGreetingText('John')); // "Hello, John!"
Pluralization with Freezed
Handle pluralization with custom methods:
@freezed
class CommonTranslations with _$CommonTranslations {
const CommonTranslations._();
const factory CommonTranslations({
required String appName,
required PluralTranslation messages,
}) = _CommonTranslations;
factory CommonTranslations.fromJson(Map<String, dynamic> json) =>
_$CommonTranslationsFromJson(json);
}
@freezed
class PluralTranslation with _$PluralTranslation {
const PluralTranslation._();
const factory PluralTranslation({
required String zero,
required String one,
required String other,
}) = _PluralTranslation;
factory PluralTranslation.fromJson(Map<String, dynamic> json) =>
_$PluralTranslationFromJson(json);
String format(int count) {
if (count == 0) return zero;
if (count == 1) return one.replaceAll('{count}', count.toString());
return other.replaceAll('{count}', count.toString());
}
}
JSON:
{
"common": {
"appName": "My App",
"messages": {
"zero": "No messages",
"one": "1 message",
"other": "{count} messages"
}
}
}
Extension Methods for Cleaner Access
Create extension methods for even cleaner syntax:
// lib/l10n/extensions.dart
import 'package:flutter/widgets.dart';
import 'app_localizations_delegate.dart';
import 'translations.dart';
extension LocalizationExtension on BuildContext {
AppTranslations get tr => AppLocalizations.of(this).translations;
CommonTranslations get trCommon => AppLocalizations.of(this).common;
HomeTranslations get trHome => AppLocalizations.of(this).home;
AuthTranslations get trAuth => AppLocalizations.of(this).auth;
SettingsTranslations get trSettings => AppLocalizations.of(this).settings;
}
Usage becomes very clean:
Text(context.trHome.title);
Text(context.trCommon.loading);
Text(context.trAuth.login);
Testing Type-Safe Translations
Write tests with confidence:
// test/translations_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/l10n/translations.dart';
void main() {
group('AppTranslations', () {
test('should parse English translations correctly', () {
final json = {
'common': {
'appName': 'My App',
'loading': 'Loading...',
'error': 'Error',
'retry': 'Retry',
'cancel': 'Cancel',
'save': 'Save',
'delete': 'Delete',
'confirm': 'Confirm',
},
'home': {
'title': 'Home',
'welcome': 'Welcome!',
'subtitle': 'Subtitle',
'getStarted': 'Get Started',
},
'auth': {
'login': 'Login',
'logout': 'Logout',
'email': 'Email',
'password': 'Password',
'forgotPassword': 'Forgot?',
'createAccount': 'Create',
'loginError': 'Error',
},
'settings': {
'title': 'Settings',
'language': 'Language',
'theme': 'Theme',
'notifications': 'Notifications',
'privacy': 'Privacy',
'about': 'About',
},
};
final translations = AppTranslations.fromJson(json);
expect(translations.common.appName, 'My App');
expect(translations.home.title, 'Home');
expect(translations.auth.login, 'Login');
expect(translations.settings.language, 'Language');
});
test('should throw when required field is missing', () {
final invalidJson = {
'common': {
'appName': 'My App',
// missing other required fields
},
};
expect(
() => AppTranslations.fromJson(invalidJson),
throwsA(isA<TypeError>()),
);
});
});
}
Best Practices
1. Organize by Feature
Structure your translation models by feature for better maintainability:
@freezed
class AppTranslations with _$AppTranslations {
const factory AppTranslations({
required CommonTranslations common,
required OnboardingTranslations onboarding,
required AuthTranslations auth,
required DashboardTranslations dashboard,
required ProfileTranslations profile,
required SettingsTranslations settings,
required ErrorTranslations errors,
}) = _AppTranslations;
}
2. Use Validation Scripts
Create a script to validate translation completeness:
// tools/validate_translations.dart
import 'dart:convert';
import 'dart:io';
void main() async {
final baseFile = File('assets/translations/en.json');
final baseJson = json.decode(await baseFile.readAsString());
final baseKeys = _extractKeys(baseJson);
final translationDir = Directory('assets/translations');
final errors = <String>[];
await for (final file in translationDir.list()) {
if (file.path.endsWith('.json') && !file.path.endsWith('en.json')) {
final content = await File(file.path).readAsString();
final localeJson = json.decode(content);
final localeKeys = _extractKeys(localeJson);
final missing = baseKeys.difference(localeKeys);
final extra = localeKeys.difference(baseKeys);
if (missing.isNotEmpty) {
errors.add('${file.path}: Missing keys: $missing');
}
if (extra.isNotEmpty) {
errors.add('${file.path}: Extra keys: $extra');
}
}
}
if (errors.isEmpty) {
print('All translations are valid!');
} else {
errors.forEach(print);
exit(1);
}
}
Set<String> _extractKeys(Map<String, dynamic> json, [String prefix = '']) {
final keys = <String>{};
json.forEach((key, value) {
final fullKey = prefix.isEmpty ? key : '$prefix.$key';
if (value is Map<String, dynamic>) {
keys.addAll(_extractKeys(value, fullKey));
} else {
keys.add(fullKey);
}
});
return keys;
}
3. Generate TypeScript Types for Backend
If your translations come from a CMS, generate matching types:
// tools/generate_ts_types.dart
void generateTypeScriptTypes() {
// Generate TypeScript interfaces from Freezed models
// Ensures frontend and backend stay in sync
}
Conclusion
Combining Freezed with Flutter localization provides:
- Compile-time safety for all translation keys
- IDE autocompletion that speeds up development
- Refactoring support across your entire codebase
- Type-safe parameters for dynamic translations
- Testable translation logic with pure Dart models
This approach works especially well for large applications where translation errors can be costly. The upfront investment in code generation pays off quickly as your app grows.
For managing your translation files across multiple locales, consider using FlutterLocalisation to streamline your workflow with visual editing, AI translations, and Git integration.