Dart moderne
Le langage avant le framework. Un entretien Flutter senior commence presque toujours par Dart : null safety, final contre const, mixins, puis les apports de Dart 3 (records, patterns, sealed classes) et les nouveautés jusqu'à Dart 3.13. Ce chapitre vous remet à niveau sur tout ce qu'on peut vous demander d'expliquer ou d'écrire au tableau.
Objectifs
- Expliquer la sound null safety et la promotion de type, y compris pourquoi elle échoue sur un champ public.
- Distinguer
finaletconst, et justifier l'intérêt des const constructors en Flutter. - Choisir entre héritage, mixin, extension et extension type.
- Modéliser des états avec une sealed class et un switch exhaustif, sans IA.
- Connaître les nouveautés de Dart 3.6 à 3.13 et savoir dire ce qui est stable ou non.
Sound null safety
Depuis Dart 2.12, et obligatoirement depuis Dart 3, un type n'accepte pas null sauf s'il est suffixé par ?. Le mot sound (fiable) signifie que le système de types le garantit : si une expression a le type String, elle ne peut jamais valoir null à l'exécution. Le compilateur peut donc supprimer des vérifications, et l'erreur la plus fréquente des applications mobiles (le null pointer) est déplacée du runtime vers la compilation.
String nom = 'Amina'; // jamais null
String? surnom; // null par défaut
int longueur(String? s) {
if (s == null) return 0;
return s.length; // promotion : s est un String ici
}
// ! : "je garantis que ce n'est pas null", sinon TypeError au runtime
int forcer(String? s) => s!.length;
// ?. et ?? : accès conditionnel et valeur par défaut
int longueurSure(String? s) => s?.length ?? 0;
class Profil {
// required : paramètre nommé obligatoire, non nullable
Profil({required this.id, this.bio});
final String id;
final String? bio;
}
late
late dit au compilateur : « ce champ non nullable sera initialisé avant sa première lecture, faites-moi confiance ». Deux usages légitimes : l'initialisation différée (un champ affecté dans initState) et l'initialisation paresseuse (lazy) d'un champ coûteux. Si la promesse est rompue, la lecture lève une LateInitializationError à l'exécution. Vous avez échangé une garantie statique contre une vérification dynamique.
class Rapport {
// calculé une seule fois, à la première lecture
late final String resume = _calculerResume();
String _calculerResume() => 'résumé coûteux';
}
Promotion de type et ses limites
La promotion (type promotion) fonctionne sur les variables locales et les paramètres : après if (x != null), x est traité comme non nullable. Elle ne fonctionne pas sur un champ public, car un champ est en réalité un getter : une sous-classe peut le redéfinir et renvoyer une valeur différente au second appel. Depuis Dart 3.2, les champs privés et final sont promus, à condition qu'aucun getter concret du même nom n'existe dans la bibliothèque.
class Compte {
double? plafond;
void verifier() {
if (plafond != null) {
print(plafond * 2); // erreur : champ public, pas de promotion
}
}
}
class Compte {
double? plafond;
void verifier() {
// copie locale, ou pattern if-case
if (plafond case final p?) {
print(p * 2);
}
}
}
« Pourquoi ne pas mettre ! partout ? » Parce que ! réintroduit exactement le crash que la null safety supprime. En revue de code, chaque ! doit être justifié par un invariant qu'on peut énoncer. De même, un late non initialisé produit un crash en production que le compilateur aurait pu éviter : préférez un type nullable ou une initialisation dans le constructeur quand c'est possible.
final et const
final signifie « affecté une seule fois », à l'exécution. const signifie « connu à la compilation » et implique une immutabilité profonde. Une liste final reste modifiable : c'est la variable qui ne peut pas être réaffectée, pas l'objet. Une liste const est non modifiable et sa modification lève une UnsupportedError.
final liste = [1, 2];
liste.add(3); // autorisé : l'objet est mutable
// liste = []; // erreur : variable final
const fixe = [1, 2];
// fixe.add(3); // compile, mais UnsupportedError au runtime
final now = DateTime.now(); // OK : valeur calculée au runtime
// const t = DateTime.now(); // erreur : pas une constante de compilation
Les valeurs const sont canonicalisées : deux expressions const identiques désignent la même instance en mémoire. C'est ce qui rend les widgets const intéressants en Flutter : le framework compare l'ancien et le nouveau widget, constate qu'ils sont identiques (identical) et court-circuite la reconstruction du sous-arbre.
class Couleur {
// tous les champs doivent être final pour un const constructor
const Couleur(this.r, this.g, this.b);
final int r, g, b;
}
void main() {
const a = Couleur(10, 20, 30);
const b = Couleur(10, 20, 30);
print(identical(a, b)); // true : même instance
final c = Couleur(10, 20, 30); // pas const : nouvelle instance
print(identical(a, c)); // false
}
« final fixe la référence à l'exécution, const fixe la valeur à la compilation et rend l'objet profondément immuable. Les constantes sont canonicalisées, donc deux const identiques sont la même instance. En Flutter, c'est ce qui permet à un widget const d'être réutilisé sans rebuild de son sous-arbre, et c'est pour ça que le lint prefer_const_constructors est activé dans nos projets. »
Types et génériques
Dart est statiquement typé avec inférence. Au sommet de la hiérarchie se trouve Object? ; Never est en bas (type d'une expression qui ne se termine jamais, comme throw). Deux types se ressemblent mais n'ont rien à voir :
| Type | Accepte | Appels de méthodes | Usage |
|---|---|---|---|
Object? | tout, y compris null | vérifiés : il faut tester le type avant | le choix par défaut pour « n'importe quoi » |
dynamic | tout | non vérifiés : erreur au runtime si la méthode n'existe pas | interop, JSON brut, à confiner |
Les génériques acceptent des bornes (bounds) avec extends. Ils sont réifiés : contrairement à Java, le type List<int> existe à l'exécution et is List<int> fonctionne.
T maximum<T extends Comparable<T>>(List<T> items) {
return items.reduce((a, b) => a.compareTo(b) >= 0 ? a : b);
}
final m = maximum([3, 9, 4]); // T inféré à int
Les génériques de classe sont covariants : une List<int> est une List<num>. C'est pratique en lecture, mais non sûr en écriture, et Dart compense par une vérification au runtime.
List<num> nombres = <int>[1, 2];
nombres.add(1.5); // compile, puis TypeError à l'exécution
« dynamic et Object?, c'est pareil ? » Non. Les deux acceptent toute valeur, mais dynamic désactive la vérification statique des appels. Un jsonDecode renvoie dynamic : la bonne pratique est de le convertir immédiatement en Map<String, Object?> puis de typer via un modèle. Activez strict-casts et strict-raw-types dans analysis_options.yaml sur un projet d'équipe.
Fonctions
Les fonctions sont des objets de première classe. Les paramètres sont positionnels obligatoires, positionnels optionnels entre crochets, ou nommés entre accolades (optionnels sauf required). Une closure capture les variables de son environnement par référence. Un tear-off est une référence à une fonction, une méthode ou un constructeur sans l'appeler.
String saluer(String nom, [String titre = '']) => 'Bonjour $titre $nom';
void envoyer({required String a, String objet = '(sans objet)'}) {}
Function() compteur() {
var n = 0;
return () => ++n; // capture n : l'état survit à l'appel
}
class Point {
Point(this.x);
final int x;
}
void main() {
final c = compteur();
c();
print(c()); // 2
final noms = ['a', 'b'];
noms.forEach(print); // tear-off de fonction
final points = [1, 2].map(Point.new); // tear-off de constructeur
print(points.length);
}
Constructeurs
Dart offre plusieurs formes de constructeurs, chacune avec un rôle précis. Savoir les nommer est un signal de séniorité.
class Montant {
// generative avec initializing formals et initializer list
Montant(this.centimes, {this.devise = 'MAD'})
: assert(centimes >= 0, 'montant négatif');
// named + redirecting : délègue au constructeur principal
Montant.zero() : this(0);
// factory : peut renvoyer une instance existante, un sous-type, ou valider
factory Montant.depuisTexte(String s) {
final v = int.tryParse(s);
if (v == null) throw FormatException('Montant invalide', s);
return Montant(v);
}
final int centimes;
final String devise;
}
class Devise {
// const constructor : instances canonicalisables
const Devise(this.code);
final String code;
}
- Initializer list (après
:) : s'exécute avant le corps, sert à initialiser les champsfinal, appelersuperet poser desassert. - Redirecting :
: this(...), sans corps. - Factory : pas d'accès à
this, peut renvoyer un cache, un sous-type, ou lever une exception. C'est la forme utilisée parfromJson. - Const : tous les champs
final, pas de corps. - Super parameters (Dart 2.17) :
Bouton({super.key})au lieu deBouton({Key? key}) : super(key: key).
Mixins, extensions
Dart a l'héritage simple. Un mixin permet de réutiliser du code dans plusieurs hiérarchies sans héritage multiple. La clause on restreint les classes qui peuvent l'utiliser et donne accès à leurs membres. Depuis Dart 3, une classe ordinaire ne peut plus être utilisée comme mixin : il faut la déclarer mixin ou mixin class (utilisable à la fois comme classe et comme mixin).
abstract class Journalisable {
String get tag;
}
mixin Journal on Journalisable {
void log(String msg) => print('[$tag] $msg');
}
class ServicePaiement extends Journalisable with Journal {
@override
String get tag => 'paiement';
}
mixin class Horodatage {
DateTime get maintenant => DateTime.now().toUtc();
}
Une extension ajoute des méthodes à un type existant, sans le modifier ni l'hériter. La résolution est statique : elle dépend du type déclaré, pas du type réel. Elle ne fonctionne donc pas sur une variable dynamic.
extension TelephoneMaroc on String {
bool get estMobileMaroc => RegExp(r'^0[67]\d{8}$').hasMatch(this);
}
void main() {
print('0600000001'.estMobileMaroc); // true
}
« J'hérite quand il y a une vraie relation "est un" et un contrat commun. J'utilise un mixin pour un comportement transversal partagé entre des classes sans lien, par exemple le TickerProviderStateMixin de Flutter. J'utilise une extension pour ajouter de la commodité à un type que je ne contrôle pas, comme context.theme. Et je préfère la composition quand le comportement doit être remplaçable en test. »
Class modifiers de Dart 3
Avant Dart 3, toute classe pouvait être étendue et implémentée par n'importe qui. Une bibliothèque ne pouvait donc jamais faire évoluer une classe sans risquer de casser un utilisateur. Les modificateurs de classe contrôlent ce que les autres bibliothèques (fichiers, au sens Dart) ont le droit de faire. À l'intérieur de la même bibliothèque, tout reste permis.
| Modificateur | Instancier | Étendre ailleurs | Implémenter ailleurs | Intention |
|---|---|---|---|---|
abstract | non | oui | oui | contrat partiel |
base | oui | oui (sous-classes base, final ou sealed) | non | garantir que l'implémentation de base s'exécute |
interface | oui | non | oui | pur contrat, l'implémentation interne peut évoluer |
final | oui | non | non | hiérarchie fermée |
sealed | non (abstrait) | non | non | ensemble fermé de sous-types, switch exhaustif |
mixin class | oui | oui | oui | classe aussi utilisable comme mixin |
On les combine : abstract interface class Repository est l'équivalent d'une interface Java (pas d'instanciation, pas d'héritage d'implémentation hors de la bibliothèque). C'est la forme idéale pour un contrat de repository dans une Clean Architecture.
abstract interface class TransactionRepository {
Future<List<String>> recentes();
}
// dans un autre fichier : implements autorisé, extends interdit
class FakeTransactionRepository implements TransactionRepository {
@override
Future<List<String>> recentes() async => ['t1'];
}
Records
Un record est un agrégat anonyme, immuable, typé, avec une égalité structurelle : deux records sont égaux si leurs champs le sont. Il évite de créer une classe pour renvoyer deux valeurs. Les champs positionnels se lisent avec $1, $2, les champs nommés par leur nom.
(int, String) paire = (1, 'un');
({double lat, double lng}) position = (lat: 33.57, lng: -7.59);
(int total, int nbEchecs) bilan(List<bool> resultats) {
final echecs = resultats.where((r) => !r).length;
return (resultats.length, echecs);
}
void main() {
final (total, echecs) = bilan([true, false, true]); // destructuring
print('$total $echecs');
print(paire.$2); // un
print(position.lat);
print((1, 'a') == (1, 'a')); // true : égalité structurelle
}
« Un record remplace-t-il une classe de modèle ? » Non. Un record n'a ni méthodes, ni nom, ni sérialisation, ni documentation sur ses champs. Il convient aux retours multiples locaux et aux clés composites de Map. Un modèle métier exposé dans une API publique reste une classe.
Patterns
Les patterns décrivent la forme d'une valeur pour la tester et la déstructurer en une seule expression. Ils apparaissent dans les déclarations, les switch, les if-case et les boucles for. Le switch expression renvoie une valeur, n'a pas de break, et le compilateur vérifie qu'il est exhaustif.
String decrire(Object? valeur) => switch (valeur) {
null => 'rien',
int n when n < 0 => 'entier négatif', // guard when
int() => 'entier',
String(length: 0) => 'chaîne vide', // object pattern
[final premier, ...] => 'liste commençant par $premier',
{'code': String code} => 'erreur $code', // map pattern
(int a, int b) => 'paire $a, $b', // record pattern
_ => 'autre',
};
String mention(int note) => switch (note) {
>= 16 => 'très bien', // relational pattern
>= 12 && < 16 => 'bien',
_ => 'à revoir',
};
void lire(Map<String, Object?> json) {
// if-case : test et extraction en une ligne
if (json case {'error': {'code': String code}}) {
print('Code erreur : $code');
}
}
« Les patterns me permettent de valider et de déstructurer un JSON ou un état en une seule expression. Le switch expression est exhaustif : si j'ajoute un cas à une sealed class, tous les switch qui ne le traitent pas cessent de compiler. Sur une grosse base de code d'équipe, c'est une sécurité que les if/else en chaîne ne donnent pas. »
Sealed classes et switch exhaustif
Une sealed class est abstraite, et ses sous-types directs doivent être déclarés dans la même bibliothèque. Le compilateur connaît donc la liste complète des cas, ce qui permet la vérification d'exhaustivité. C'est le moyen idiomatique de modéliser un état d'écran ou un résultat d'opération, l'équivalent d'un type somme (sum type) en Kotlin ou en Swift.
sealed class EtatTransfert {
const EtatTransfert();
}
final class Saisie extends EtatTransfert {
const Saisie();
}
final class EnCours extends EtatTransfert {
const EnCours();
}
final class Reussi extends EtatTransfert {
const Reussi(this.transactionId);
final String transactionId;
}
final class Echoue extends EtatTransfert {
const Echoue(this.code, this.message);
final String code;
final String message;
}
String libelle(EtatTransfert etat) => switch (etat) {
Saisie() => 'Saisissez un montant',
EnCours() => 'Envoi...',
Reussi(:final transactionId) => 'Transfert $transactionId effectué',
Echoue(code: 'INSUFFICIENT_FUNDS') => 'Solde insuffisant',
Echoue(:final message) => message,
};
Pas de clause _ ou default : c'est volontaire. Le jour où un état EnAttenteOtp est ajouté, la fonction libelle ne compile plus et l'oubli est détecté avant la recette.
Sealed class ou enum enrichi ?
Un enum enrichi (enhanced enum, Dart 2.17) peut avoir des champs, un constructeur const et des méthodes, et un switch sur un enum est lui aussi exhaustif. Règle simple : un enum quand chaque cas est une constante sans données propres ; une sealed class quand les cas portent des données différentes.
enum Canal {
mobile('Application', true),
agence('Agence', false);
const Canal(this.libelle, this.numerique);
final String libelle;
final bool numerique;
}
« Pourquoi ajouter un default à un switch sur une sealed class ? » Il ne faut pas : un default ou un _ fait disparaître l'erreur de compilation quand un nouveau cas apparaît. L'exhaustivité ne vous protège que si vous la laissez faire son travail. Autre piège : les sous-classes doivent être dans la même bibliothèque, donc pas dans des fichiers séparés, sauf via part.
Extension types
Un extension type (Dart 3.3) est un enveloppeur à coût nul : à la compilation, c'est un type distinct avec sa propre API ; à l'exécution, il n'existe pas et seule la valeur sous-jacente (la representation) est manipulée. On l'utilise pour donner un sens métier à un type primitif sans allocation : un identifiant, un numéro de téléphone, ou un montant en centimes.
extension type const Money(int centimes) implements Object {
Money.dirhams(int dh) : centimes = dh * 100;
Money operator +(Money autre) => Money(centimes + autre.centimes);
Money operator -(Money autre) => Money(centimes - autre.centimes);
bool operator <(Money autre) => centimes < autre.centimes;
bool get estNegatif => centimes < 0;
String formater() {
final signe = centimes < 0 ? '-' : '';
final abs = centimes.abs();
final cts = (abs % 100).toString().padLeft(2, '0');
return '$signe${abs ~/ 100},$cts MAD';
}
}
void main() {
final solde = Money(125_000);
final frais = Money.dirhams(5);
print((solde - frais).formater()); // 1245,00 MAD
// Money m = 15000; // erreur : un int n'est pas un Money
}
Trois points à connaître. D'abord, l'enveloppe disparaît au runtime : Money(5) is int vaut true, et un cast as Money ne vérifie rien. Ce n'est donc pas une frontière de sécurité, seulement une aide au typage. Ensuite, sans implements Object, un extension type est considéré comme potentiellement nullable et ne peut pas être affecté à Object. Enfin, implements int exposerait toute l'API de int, ce qu'on évite pour un montant (multiplier deux montants n'a pas de sens).
« Pour l'argent, je n'utilise jamais de double : je stocke des centimes en int, et je l'enveloppe dans un extension type Money. J'obtiens un type distinct, impossible à confondre avec un identifiant ou une quantité, avec seulement les opérations qui ont un sens, et sans aucun coût d'allocation. Si j'ai besoin d'une vraie validation au runtime, je prends une classe. »
Nouveautés de Dart 3.6 à 3.13
Dart 3.13 (août 2026, livré avec Flutter 3.47) rend stables les primary constructors et la syntaxe courte new / factory. C'est le changement de syntaxe le plus visible depuis Dart 3.0.
| Version | Fonctionnalité | Exemple |
|---|---|---|
| 3.6 | Digit separators | 1_000_000 |
| 3.7 | Wildcard _ non liant, nouveau style de dart format | (_, _) => 0 |
| 3.8 | Null-aware elements | [1, ?peutEtreNull] |
| 3.10 | Dot shorthands | mainAxisAlignment: .center |
| 3.12 | Private named parameters | Repo({required this._url}) |
| 3.13 | Primary constructors, new / factory courts | class Point(final int x, final int y); |
Wildcard et null-aware elements
void main() {
// 3.7 : _ ne déclare plus de variable, on peut le répéter
final paires = [(1, 'a'), (2, 'b')];
for (final (_, lettre) in paires) {
print(lettre);
}
void rappel(int _, String _) {}
rappel(1, 'x');
// 3.8 : l'élément n'est ajouté que s'il n'est pas null
String? note;
final champs = ['montant', ?note, 'date'];
print(champs); // [montant, date]
}
Dot shorthands (Dart 3.10)
Quand le type attendu est connu par le contexte, on peut omettre le nom du type devant un membre statique, une valeur d'enum ou un constructeur. Sans type de contexte, la forme courte ne compile pas.
enum Statut { termine, enAttente, echoue }
Statut depuisApi(String s) => switch (s) {
'completed' => .termine,
'pending' => .enAttente,
_ => .echoue,
};
void main() {
Statut s = .enAttente; // contexte : Statut
int n = .parse('42'); // membre statique de int
// var x = .termine; // erreur : aucun type de contexte
print('$s $n ${depuisApi('completed')}');
}
En Flutter, cela donne Column(mainAxisAlignment: .center, children: [...]) ou padding: const .all(16).
Private named parameters (Dart 3.12)
Avant 3.12, un champ privé ne pouvait pas être initialisé par un paramètre nommé this._x, ce qui obligeait à écrire une initializer list. Désormais, l'appelant écrit le nom sans le soulignement.
class ApiClient {
ApiClient({required this._baseUrl});
final String _baseUrl;
String get baseUrl => _baseUrl;
}
final client = ApiClient(baseUrl: 'http://10.0.2.2:8080/api/v1');
Primary constructors (Dart 3.13)
Un primary constructor déclare le constructeur et les champs dans l'en-tête de la classe. Un paramètre précédé de final ou var est un declaring parameter : il crée le champ. Un paramètre sans modificateur est un paramètre ordinaire, utilisable seulement pendant l'initialisation. Un corps vide peut être remplacé par ;. Les assertions et l'initializer list passent dans le corps, après this :.
// équivaut à deux champs final et un constructeur const
class const Coordonnee(final double lat, final double lng);
class Beneficiaire(final String id, final String nom, {required final String telephone}) {
// initializer list et assert du primary constructor
this : assert(nom.length > 1);
// les paramètres sont visibles dans les initialiseurs non late
final String initiale = nom[0].toUpperCase();
// tout autre constructeur génératif doit rediriger vers le primary
new inconnu(String id) : this(id, 'Inconnu', telephone: '');
// syntaxe courte de factory : plus besoin de répéter le nom de classe
factory fromJson(Map<String, Object?> json) => Beneficiaire(
json['id'] as String,
json['name'] as String,
telephone: json['phone'] as String,
);
}
Les super parameters s'écrivent aussi dans l'en-tête, et un primary constructor peut être nommé ou privé :
class Personne(final String nom);
class Agent(super.nom, final String agence) extends Personne;
class Jeton._(final String valeur) {
factory depuis(String brut) => Jeton._(brut.trim());
}
La syntaxe courte new / factory fonctionne aussi dans une classe classique : new(this.url);, new local() : url = 'localhost';, factory create() => .... Elle exige le langage 3.13, donc sdk: ^3.13.0 dans le pubspec.yaml. Trois lints accompagnent la migration : use_declaring_parameters, unnecessary_type_name_in_constructor et empty_container_bodies (; au lieu de {}), avec des correctifs applicables par dart fix --apply.
Dart 3.13 contient un changement cassant : final et var sont désormais interdits sur les paramètres qui ne déclarent pas de champ, puisqu'ils sont réservés aux primary constructors. Un ancien void f(final int x) ne compile plus en langage 3.13. Le lint parameter_assignments couvre l'intention d'origine (ne pas réaffecter un paramètre). Autre limite : un primary constructor ne peut pas lire ses paramètres dans un initialiseur late.
Macros abandonnées, augmentations à venir
Les macros statiques, annoncées comme le remplaçant de build_runner, ont été abandonnées en janvier 2025 : leur coût sur les performances de l'analyseur et du hot reload était trop élevé. L'équipe Dart a repris une partie du travail sous forme d'augmentations (compléter une déclaration depuis un autre fichier, ce dont profite la génération de code) et investit dans un build_runner plus rapide. À la date de rédaction, les augmentations figurent sur la feuille de route 2026 et ne sont pas une fonctionnalité stable : freezed, json_serializable et riverpod_generator passent toujours par build_runner.
Collections
List est ordonnée et indexée, Set garantit l'unicité (selon == et hashCode), Map associe des clés uniques à des valeurs. Les littéraux par défaut sont des LinkedHashSet et LinkedHashMap, donc l'ordre d'insertion est conservé. Le spread et les collection-if / collection-for permettent de construire une collection de façon déclarative, ce qui est omniprésent dans les listes children de Flutter.
void main() {
final base = [1, 2];
List<int>? extra;
final estPro = base.length > 1;
final tout = [
0,
...base,
...?extra, // spread null-aware
if (estPro) 99 else -1,
for (final i in base) i * 10,
];
print(tout); // [0, 1, 2, 99, 10, 20]
final parType = <String, List<int>>{};
parType.putIfAbsent('payment', () => []).add(15000);
final vue = List.unmodifiable(tout); // copie non modifiable
print(vue.length);
}
Les méthodes comme map et where renvoient un Iterable paresseux : le calcul est refait à chaque parcours. Appelez toList() quand le résultat est réutilisé, sinon un map coûteux peut s'exécuter plusieurs fois.
Égalité : == et hashCode
Par défaut, == compare les identités : deux instances aux champs identiques sont différentes. Pour un objet valeur (un modèle, un état), il faut redéfinir == et hashCode de façon cohérente : deux objets égaux doivent avoir le même hashCode, sinon Set et Map se comportent de façon incohérente.
class Beneficiaire {
const Beneficiaire(this.id, this.nom);
final String id;
final String nom;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Beneficiaire && other.id == id && other.nom == nom;
@override
int get hashCode => Object.hash(id, nom);
}
Écrire cela pour chaque modèle est fastidieux et fragile : ajouter un champ sans mettre à jour == crée un bug silencieux. D'où deux solutions répandues : equatable (on liste les champs dans props, sans génération de code) et freezed (génération de ==, hashCode, copyWith, toString, unions et sérialisation JSON avec json_serializable). Depuis freezed 3, la classe annotée se déclare abstract ou sealed. L'égalité de valeur compte beaucoup en Flutter : Riverpod et les select ne notifient que si le nouvel état est différent de l'ancien.
Un modèle avec un champ List : [1] == [1] vaut false en Dart, car les listes comparent l'identité. Une égalité écrite à la main qui compare other.items == items est donc fausse. freezed gère ce cas avec une égalité profonde (DeepCollectionEquality), à la main il faut listEquals ou le package collection.
Gestion d'erreurs
Dart distingue deux familles. Une Error signale un bug du programme (StateError, ArgumentError, RangeError, TypeError) : on ne l'attrape pas pour continuer, on corrige le code. Une Exception signale une situation prévisible qu'on doit traiter (FormatException, TimeoutException, SocketException, ou vos propres exceptions métier).
import 'dart:async';
class SoldeInsuffisant implements Exception {
const SoldeInsuffisant(this.manque);
final int manque;
@override
String toString() => 'SoldeInsuffisant(manque: $manque)';
}
Future<void> payer(Future<void> Function() appel) async {
try {
await appel().timeout(const Duration(seconds: 10));
} on SoldeInsuffisant catch (e) {
print('Il manque ${e.manque} centimes'); // cas métier traité
} on TimeoutException {
print('Délai dépassé');
} catch (e, stackTrace) {
// journaliser avec la stack trace, puis propager
print('Inattendu : $e\n$stackTrace');
rethrow; // conserve la stack trace d'origine
} finally {
print('fin du paiement'); // toujours exécuté
}
}
« Je ne laisse pas les exceptions techniques remonter jusqu'à l'UI. La couche data attrape les exceptions réseau et les traduit en échecs métier typés, souvent une sealed class Failure ou un type Result, ce qui force l'appelant à traiter chaque cas via un switch exhaustif. J'utilise rethrow plutôt que throw e pour ne pas perdre la stack trace, et je n'attrape jamais une Error pour continuer. »
Questions d'entretien
Quelle est la différence entre final et const ?
final : une seule affectation, valeur calculée à l'exécution, l'objet référencé peut rester mutable. const : valeur connue à la compilation, immutabilité profonde, instances canonicalisées (deux const identiques sont le même objet). En Flutter, un widget const est réutilisé tel quel et son sous-arbre n'est pas reconstruit.
À quoi sert late, et quel est le risque ?
À déclarer un non-nullable initialisé plus tard (dans initState, par exemple) ou paresseusement (late final x = calcul();, évalué à la première lecture). Le risque est une LateInitializationError au runtime si on lit avant d'avoir affecté : on perd la garantie statique. À réserver aux cas où l'invariant est évident.
Pourquoi la promotion de type ne marche-t-elle pas sur un champ ?
Un champ public est accédé via un getter qui peut être redéfini dans une sous-classe, donc deux lectures successives peuvent renvoyer des valeurs différentes. Le compilateur refuse de promouvoir. Depuis Dart 3.2, les champs privés et final sont promus. Sinon : copie dans une variable locale ou if (champ case final v?).
Mixin ou héritage : comment choisissez-vous ?
L'héritage exprime une relation « est un » avec une seule superclasse. Le mixin partage un comportement entre classes sans lien hiérarchique, et on peut en appliquer plusieurs. La clause on limite les classes hôtes. Depuis Dart 3, il faut déclarer mixin ou mixin class. Si le comportement doit être remplacé en test, je préfère la composition et l'injection.
Pourquoi utiliser une sealed class ?
Pour modéliser un ensemble fermé de cas qui portent des données différentes (états d'écran, résultats, erreurs). Tous les sous-types sont dans la même bibliothèque, donc le compilateur vérifie l'exhaustivité des switch : ajouter un cas casse la compilation de tout code qui l'oublie. C'est un type somme, comme les sealed classes Kotlin ou les enums à valeurs associées de Swift.
Qu'est-ce qu'un record, et quand l'utiliser ?
Un agrégat anonyme, immuable, typé, à égalité structurelle, avec champs positionnels ($1) et nommés. Idéal pour renvoyer plusieurs valeurs d'une fonction ou comme clé composite de Map. Pas pour un modèle métier public : pas de méthodes, pas de nom, pas de sérialisation.
Quelle différence entre interface class, base class et final class ?
Les trois restreignent ce que les autres bibliothèques peuvent faire. interface : implémentable mais pas extensible ailleurs. base : extensible mais pas implémentable ailleurs, ce qui garantit que le code de la classe de base s'exécute toujours. final : ni l'un ni l'autre. Dans la même bibliothèque, tout reste permis.
Qu'est-ce qu'un extension type, et en quoi diffère-t-il d'une classe enveloppe ?
Un type distinct à la compilation qui disparaît au runtime : aucune allocation, la valeur sous-jacente est manipulée directement. Il sert à typer des primitives (identifiants, montants en centimes). Contrairement à une classe, il n'offre aucune garantie au runtime : is et as voient le type sous-jacent.
dynamic et Object? : quelle différence ?
Les deux acceptent toute valeur. Object? garde la vérification statique : il faut tester le type avant d'appeler une méthode. dynamic la désactive, et une erreur n'apparaît qu'au runtime. On confine dynamic aux frontières (JSON, interop) et on convertit tout de suite en types précis.
Exception ou Error : laquelle attraper ?
On attrape les Exception, qui représentent des échecs prévisibles (réseau, format, délai). Une Error signale un bug (StateError, RangeError) : l'attraper pour continuer masque le défaut. On la laisse remonter jusqu'au gestionnaire global (Crashlytics, Sentry) pour la corriger.
Qu'apportent les primary constructors de Dart 3.13 ?
Ils déclarent les champs et le constructeur dans l'en-tête : class Point(final int x, final int y);. Les paramètres final ou var créent des champs, les autres sont de simples paramètres visibles dans les initialiseurs. Les autres constructeurs génératifs doivent rediriger vers le primary. Moins de boilerplate, mais cela ne remplace pas freezed pour l'égalité et copyWith.
Pourquoi les macros ont-elles été abandonnées ?
L'équipe Dart a arrêté le projet en janvier 2025 : le coût sur la vitesse de l'analyseur, de la compilation et du hot reload était incompatible avec l'expérience attendue. Le travail continue sous forme d'augmentations et d'un build_runner plus rapide. En pratique, la génération de code passe toujours par build_runner.
Exercices
Dans un fichier Dart vierge, écrivez une sealed class Resultat<T> avec deux sous-types Succes<T>(T valeur) et Echec<T>(String code), puis une fonction String afficher(Resultat<int> r) en switch expression, avec un guard qui affiche « zéro » quand la valeur vaut 0.
Critères : dart analyze sans avertissement ; pas de default ni de _ ; ajouter un troisième sous-type doit faire échouer la compilation de afficher.
Écrivez la fonction String? codeErreur(Object? json) qui extrait error.code d'un corps d'erreur de l'API ({"error": {"code": "...", "message": "..."}}) avec un seul if-case, et renvoie null pour toute autre forme.
Critères : aucun cast as, aucun ! ; testé sur 4 entrées (corps valide, null, liste, code non textuel).
Chronométrez-vous en expliquant à voix haute, en moins de 2 minutes chacune : final contre const, les cinq class modifiers, et pourquoi un switch sur une sealed class ne doit pas avoir de default.
Critères : pas d'hésitation sur la canonicalisation, un exemple concret par notion.
Créez le package de modèles de l'app Wallet (voir le projet), dans lib/features/transactions/domain/ :
- Un extension type
Moneysurint(centimes), avec addition, soustraction, comparaison et un formatage1 250,00 MAD. Jamais dedouble. TransactionTypepour les six valeurs du contrat (transfer_in,transfer_out,payment,cash_in,cash_out,bill), avec la conversion depuis et vers la valeur JSON. Choisissez entre enum enrichi et sealed class, et écrivez la justification en commentaire. Indice : le contrat ne donne aucune donnée propre à chaque type, ce qui plaide pour un enum enrichi ; une sealed class se justifie si vous prévoyez des données par cas (par exemple le facturier d'unbill).- Deux enums
Direction(credit,debit) etTransactionStatus. - Une classe
Transactionimmuable avec égalité de valeur, et unfactory fromJsonqui utilise un pattern de map pour valider la forme du JSON et lève uneFormatExceptionsinon. - Une fonction qui renvoie un record
(Money credits, Money debits)à partir d'une liste de transactions.
Critères : dart analyze propre avec strict-casts ; un test unitaire par type de transaction ; un JSON avec un type inconnu ne fait pas planter l'app mais produit une erreur explicite. freezed viendra au chapitre 08 : ici, écrivez l'égalité à la main une fois pour comprendre ce qu'il génère.
Quiz
12 questions. Corrigez, relisez les explications, refaites-le 48 h plus tard.