.map().filter() contre .flatMap()
💡 Sujet
map et filter sont les deux premiers outils qu'on apprend sur les tableaux, et on finit
naturellement par les enchaîner : transformer, puis jeter ce qui ne convient pas. flatMap, lui,
reste souvent ignoré parce que son nom parle d'aplatissement, ce qui semble être un tout autre
sujet. C'est pourtant l'opérateur qui fait ces deux étapes en une seule, et il suffit d'un déclic
pour le voir.
🧱 Les trois briques, une ligne chacune
[1, 2, 3].map(n => n * 2); // [ 2, 4, 6 ] → 3 entrent, 3 sortent
[1, 2, 3].filter(n => n > 1); // [ 2, 3 ] → 3 entrent, 2 sortent, inchangés
[1, 2, 3].flatMap(n => [n, n]); // [ 1, 1, 2, 2, 3, 3 ] → 3 entrent, 6 sortentmaptransforme : N éléments entrent, N sortent.filtersélectionne : N entrent, N ou moins sortent, sans être modifiés.flatMap=map+ aplatissement d'un niveau : le callback renvoie un tableau, et tous ces tableaux sont recollés bout à bout.
Autrement dit, flatMap est exactement ceci :
const doublons = [1, 2, 3].map(n => [n, n]); // [ [ 1, 1 ], [ 2, 2 ], [ 3, 3 ] ]
console.log(doublons.flat()); // [ 1, 1, 2, 2, 3, 3 ]
console.log([1, 2, 3].flatMap(n => [n, n])); // [ 1, 1, 2, 2, 3, 3 ] ← identique💡 Le déclic : ce que renvoie le callback
Tout flatMap se lit à travers la taille du tableau que renvoie son callback, élément par élément :
| Le callback renvoie | Effet sur l'élément |
|---|---|
[] | il disparaît du résultat → effet filter |
[x] | il est transformé en x → effet map |
[a, b] | il se démultiplie en deux éléments |
const resultat = [10, 20, 30].flatMap(n => {
if (n === 10) return []; // 10 disparaît
if (n === 20) return [n * 2]; // 20 devient 40
return [n, n]; // 30 devient 30, 30
});
console.log(resultat); // [ 40, 30, 30 ]C'est parce que [] fait disparaître un élément que flatMap sait faire map et filter en un
seul passage : renvoyer un tableau vide, c'est décider de ne rien contribuer au résultat.
🔍 Comparaison concrète
Cas classique : une liste de chaînes à nettoyer, dont on jette ensuite les vides.
const entrees = [" alpha ", "", "beta", " ", "gamma "];La version .map().filter()
const intermediaire = entrees.map(s => s.trim());
console.log(intermediaire); // [ 'alpha', '', 'beta', '', 'gamma' ]
const resultat = intermediaire.filter(s => s.length > 0);
console.log(resultat); // [ 'alpha', 'beta', 'gamma' ]Écrit en chaîne, c'est le même mécanisme, le tableau intermédiaire est simplement anonyme :
console.log(entrees.map(s => s.trim()).filter(s => s.length > 0));
// [ 'alpha', 'beta', 'gamma' ]Deux choses se passent ici, et il faut les voir :
.map()construit un tableau intermédiaire complet de 5 éléments ;.filter()reprend ce tableau depuis le début et le reparcourt en entier.
Ce tableau intermédiaire contient '' deux fois : des valeurs que le reste du code n'a jamais le
droit de voir, mais qui ont bel et bien existé pendant un instant.
La version .flatMap()
const entrees = [" alpha ", "", "beta", " ", "gamma "];
const resultat = entrees.flatMap(s => {
const propre = s.trim();
return propre.length > 0 ? [propre] : [];
});
console.log(resultat); // [ 'alpha', 'beta', 'gamma' ]Un seul parcours, aucun tableau intermédiaire, et la règle « nettoyer puis jeter les vides » tient en trois lignes au même endroit.
👣 La trace pas à pas de la version flatMap
flatMap traite les éléments un par un, dans l'ordre, et concatène ce que le callback renvoie :
| Élément lu | propre après trim() | Le callback renvoie | Résultat accumulé |
|---|---|---|---|
" alpha " | "alpha" | ["alpha"] | ["alpha"] |
"" | "" | [] | ["alpha"] |
"beta" | "beta" | ["beta"] | ["alpha", "beta"] |
" " | "" | [] | ["alpha", "beta"] |
"gamma " | "gamma" | ["gamma"] | ["alpha", "beta", "gamma"] |
À vérifier soi-même en affichant chaque étape :
const entrees = [" alpha ", "", "beta", " ", "gamma "];
const resultat = entrees.flatMap(s => {
const propre = s.trim();
const renvoi = propre.length > 0 ? [propre] : [];
console.log(`lu ${JSON.stringify(s).padEnd(12)} → renvoie ${JSON.stringify(renvoi)}`);
return renvoi;
});
console.log(resultat);
// lu " alpha " → renvoie ["alpha"]
// lu "" → renvoie []
// lu "beta" → renvoie ["beta"]
// lu " " → renvoie []
// lu "gamma " → renvoie ["gamma"]
// [ 'alpha', 'beta', 'gamma' ]✅ Les vrais bénéfices, sans les survendre
Le temps d'exécution n'en fait pas partie sur de petits tableaux. Sur cinq, cent ou mille
éléments, la différence est imperceptible : deux parcours de tableau restent deux parcours de
tableau, et le moteur JS les avale sans broncher. Écrire flatMap pour « optimiser » un tableau de
dix chaînes, c'est se raconter une histoire.
Ce qu'on gagne vraiment :
La logique tient à un seul endroit. Avec .map().filter(), la condition de rejet vit dans le
filter, loin de la transformation qui l'a produite. Le jour où le nettoyage change, il faut penser
à aller relire le filtre. Avec flatMap, transformer et jeter sont deux lignes voisines.
Aucune valeur bâtarde n'est produite, même temporairement. La version .map() fabrique un
tableau contenant des '' — des valeurs invalides du point de vue métier. Elles ne survivent pas au
filter, mais elles ont existé. Avec flatMap, aucune valeur qui ne mérite pas d'être dans le
résultat n'est jamais construite.
Un calcul qui sert à la fois au test et au résultat n'est fait qu'une fois. Le piège est
surtout visible dans l'ordre inverse, filter puis map, où l'on calcule deux fois la même chose :
const entrees = [" alpha ", "", "beta", " ", "gamma "];
// ❌ trim() est appelé deux fois par élément : une fois pour tester, une fois pour produire
console.log(entrees.filter(s => s.trim().length > 0).map(s => s.trim()));
// [ 'alpha', 'beta', 'gamma' ]
// ✅ trim() est appelé une seule fois, son résultat sert au test ET au résultat
console.log(entrees.flatMap(s => {
const propre = s.trim();
return propre.length > 0 ? [propre] : [];
}));
// [ 'alpha', 'beta', 'gamma' ]Avec trim() c'est anecdotique. Avec un parsing, une expression régulière compliquée ou un calcul
coûteux, ça ne l'est plus.
Le gain de temps devient mesurable seulement sur de très grandes collections ou dans une boucle chaude appelée des milliers de fois. À exécuter pour s'en convaincre :
const entrees = Array.from({ length: 1_000_000 }, (_, i) =>
i % 3 === 0 ? " " : ` valeur-${i} `
);
console.time("map+filter");
const a = entrees.map(s => s.trim()).filter(s => s.length > 0);
console.timeEnd("map+filter");
console.time("flatMap ");
const b = entrees.flatMap(s => {
const propre = s.trim();
return propre.length > 0 ? [propre] : [];
});
console.timeEnd("flatMap ");
console.log(a.length, b.length);
// map+filter: 360.396ms
// flatMap : 217.146ms
// 666666 666666Sur un million d'éléments, les deux versions se comptent en centaines de millisecondes, du même
ordre de grandeur. L'écart entre deux exécutions du même code dépasse d'ailleurs facilement
l'écart entre les deux approches : lancer ce script plusieurs fois donne des chiffres très
différents. Autrement dit, on choisit flatMap pour la lisibilité, et on prend la vitesse en bonus
là où elle existe.
🧪 En TypeScript : la garde return [] suffit au narrowing
C'est le bénéfice le plus concret, et il n'a rien à voir avec la performance.
Soit un tableau d'objets dont un champ est optionnel, qu'on veut rendre obligatoire :
type Element = { id: number; name?: string };
type ElementNomme = { id: number; name: string };
const elements: Element[] = [
{ id: 1, name: "alpha" },
{ id: 2 },
{ id: 3, name: "gamma" },
];Avec .filter(), le compilateur ne suit pas
const nommes = elements.filter(e => e.name !== undefined);
// nommes : Element[] ← et non ElementNomme[]
nommes[0].name.toUpperCase();
// ❌ error TS2532: Object is possibly 'undefined'.À l'exécution, le tableau ne contient que des éléments avec un name. Mais filter renvoie, par
définition, un tableau du même type que celui d'entrée : le compilateur n'a aucun moyen de savoir
que la condition a écarté les cas manquants. Pour le lui expliquer, il faut écrire un type
predicate, cette syntaxe x is Foo qui existe uniquement pour ça :
const nommes = elements.filter((e): e is ElementNomme => e.name !== undefined);
// nommes : ElementNomme[] ✅
nommes[0].name.toUpperCase(); // ✅ compileÇa marche, mais on a dû répéter à la main une information que la condition contenait déjà — et un
type predicate est une promesse non vérifiée : rien n'empêche d'écrire e.name === undefined dans
le corps sans que TypeScript proteste.
Avec .flatMap(), une garde en tête du callback suffit
const nommes = elements.flatMap(e => {
if (e.name === undefined) return [];
return [{ id: e.id, name: e.name }];
});
// nommes : { id: number; name: string }[] ✅ inféré, sans type predicate
nommes[0].name.toUpperCase(); // ✅ compileLe if est un if ordinaire : après lui, TypeScript sait que e.name est un string, donc l'objet
construit à la ligne suivante a le bon type, donc le tableau final aussi. Aucune annotation, et la
garantie est vérifiée par le compilateur au lieu d'être promise par le développeur.
⚠️ Un détail qui compte : il faut bien reconstruire l'objet. Renvoyer [e] tel quel garde le
type de départ, parce que le if a affiné la lecture de e.name, pas le type de e lui-même :
const nommes = elements.flatMap(e => {
if (e.name === undefined) return [];
return [e]; // ← e est toujours de type Element
});
// nommes : Element[] ❌ le champ name est resté optionnel⚠️ Nuance honnête : depuis TypeScript 5.5, le compilateur sait déduire tout seul un type
predicate à partir d'un callback de filter simple, quand la condition affine directement le
paramètre. Ce cas-là fonctionne donc sans rien écrire :
const valeurs: (string | undefined)[] = ["alpha", undefined, "gamma"];
const propres = valeurs.filter(v => v !== undefined);
// propres : string[] ✅ inféré depuis TypeScript 5.5Cette inférence ne couvre pas les cas où l'on teste une propriété d'un objet (comme e.name
ci-dessus), ni les callbacks qui font un vrai calcul avant de décider. D'où le « souvent » : le type
predicate manuel n'a pas disparu, il s'est raréfié.
⚠️ Deux pièges classiques
1. flatMap n'aplatit qu'UN seul niveau
C'est un flat() de profondeur 1, pas un aplatissement récursif :
console.log([1, 2].flatMap(n => [[n]])); // [ [ 1 ], [ 2 ] ] ← et non [ 1, 2 ]Le premier niveau de tableau (celui que renvoie le callback) est bien retiré, mais celui qui se
trouve à l'intérieur reste. Pour aller plus loin, il faut un flat() explicite :
console.log([1, 2].flatMap(n => [[n]]).flat()); // [ 1, 2 ]
console.log([[[1]], [[2]]].flat(2)); // [ 1, 2 ] ← flat() prend une profondeur2. Oublier les crochets ne fait pas d'erreur, et c'est bien le problème
Renvoyer une valeur nue au lieu d'un tableau est autorisé : flatMap n'aplatit que les vrais
tableaux et laisse tout le reste passer tel quel.
console.log([1, 2].flatMap(n => n * 2)); // [ 2, 4 ] ← se comporte comme un map
console.log([1, 2].flatMap(n => [n * 2])); // [ 2, 4 ] ← identiqueTant qu'on veut juste transformer, les deux écritures donnent le même résultat, et c'est justement
ce qui endort. Le jour où l'on veut faire disparaître un élément, l'oubli des crochets se retourne
contre nous : c'est [] qui supprime, pas une valeur « vide ».
const entrees = [" alpha ", "", "beta"];
// ❌ on renvoie une chaîne vide en croyant supprimer l'élément
console.log(entrees.flatMap(s => s.trim()));
// [ 'alpha', '', 'beta' ] ← la chaîne vide est toujours là
// ✅ seul le tableau vide fait disparaître l'élément
console.log(entrees.flatMap(s => {
const propre = s.trim();
return propre.length > 0 ? [propre] : [];
}));
// [ 'alpha', 'beta' ]💡 Et non, une chaîne renvoyée nue n'est pas explosée en caractères, contrairement à ce qu'on lit parfois — l'aplatissement de JavaScript ne s'applique qu'aux tableaux, pas à tout ce qui est itérable :
console.log(["ab", "cd"].flatMap(s => s)); // [ 'ab', 'cd' ] ← pas [ 'a', 'b', 'c', 'd' ]
console.log(["ab", "cd"].flatMap(s => [s])); // [ 'ab', 'cd' ]Cette intuition vient d'autres langages, où une chaîne est une séquence de caractères et où le
flatMap équivalent la découpe bel et bien. En JavaScript, ce n'est pas le cas. La règle sûre
reste malgré tout de toujours renvoyer un tableau : c'est la seule écriture qui permet aussi de
supprimer et de démultiplier.
🧩 Résumé
maptransforme (N → N),filtersélectionne (N → ≤ N),flatMapfait les deux en un passage.- Tout se joue sur ce que renvoie le callback :
[]supprime,[x]transforme,[a, b]démultiplie. - Sur de petits tableaux, le gain en temps d'exécution est imperceptible — ce n'est pas la raison
de choisir
flatMap. - Les vraies raisons : la logique « transformer puis jeter » tient à un seul endroit, aucune valeur invalide n'est construite même temporairement, et un calcul qui sert au test comme au résultat n'est fait qu'une fois.
- En TypeScript, un
if (...) return []en tête du callback suffit au narrowing, là où.filter()demande souvent un type predicate(x): x is Foo => ...écrit à la main. Penser à reconstruire l'objet pour que le type affiné soit conservé. - Piège 1 :
flatMapn'aplatit qu'un seul niveau —[1, 2].flatMap(n => [[n]])donne[[1], [2]]. - Piège 2 : renvoyer une valeur nue au lieu d'un tableau ne déclenche aucune erreur, mais seul
[]fait disparaître un élément. - Le gain de vitesse ne devient mesurable que sur de très grandes collections ou dans une boucle chaude.