Le rest/spread en ES2015 / ES2018
Par Christophe Porteneuve • Publié le 14 févr. 2022

Mise à jour le 28 juin 2022, 21:48

Bienvenue dans notre cinquième article de la série JS idiomatique.

Dans notre article sur la déstructuration, on attaquait ce que j’aime appeler la « Sainte Trinité » : la déstructuration, le rest/spread, et les valeurs par défaut. Dans celui-ci, on va donc traiter du deuxième compère, et notre prochain article de la série s'occupera du dernier larron.

Si beaucoup de gens ont vite « adopté » le rest/spread tel qu'on le trouve dans ES2015 (c'est-à-dire sur itérables uniquement), il est facile de ne pas en percevoir toutes les subtilités, et puis on fait trop souvent l'impasse sur l'extension apparue avec ES2018, qui permet de les appliquer sur des objets génériques aussi, un vrai bonheur pour faire de l'immutabilité pas cher. Voyons tout ça ensemble.

Tu préfères une vidéo ?

Si tu es du genre à préférer regarder que lire pour apprendre, on a pensé à toi :

« Décomposition » ?! Je dis non.

Comme je te le disais déjà dans l’article précédent donc, je ne suis pas fan du choix de traduction dans la version française du MDN, qui recourt au terme unique « décomposition » tant pour le destructuring, dont on parlait alors, que pour le rest/spread, que nous traitons ici.

Cet amalgame est source de confusion, voire de frustration (comme tous les amalgames… #sifflote), du coup on utilisera ici les termes anglais, faute d'une traduction qualitative établie : rest et spread.

Le rest et le spread, pourquoi faire ?

L'idée, c'est de pouvoir passer d'une série de valeurs (ou paires clé-valeur) individuelles à un conteneur consolidé (Array, nouvel objet), ou réciproquement.

Les cas d'utilisation sont nombreux, mais pas forcément intuitifs, surtout pour les personnes n'ayant pas déjà une longue expérience de production en JavaScript (et qui ne sont donc pas forcément tombées sur les situations concernées).

L'opérateur est le même pour les deux usages : le ... préfixe (c'est-à-dire avant l’identifiant), par exemple ...items. C'est l'emplacement dans le code qui détermine le rôle :

  • Dans une déclaration, c'est un rest. Par exemple, au sein d'une signature de fonction ou d'une déstructuration. Un rest apparaît toujours à la fin de la liste.
  • Dans une expression, c'est un spread. Par exemple, au sein des arguments d'un appel de fonction, d'un littéral tableau ou d'un littéral objet. Un spread peut apparaître n'importe où (pas nécessairement en fin de liste), et on peut avoir plusieurs spreads dans un même contexte.

Rest positionnel

Comme son nom l'indique, un rest sert à récupérer… « le reste ».

Merci, Captain Obvious !

C'est utilisable dans les signatures de fonctions et… dans les déstructurations positionnelles (puisqu'on te dit qu'ils sont potes !).

L'idée est de regrouper les éléments de liste restants dans un véritable Array. Ça sert principalement à deux choses :

  • Implémenter des fonctions variadiques, c'est-à-dire acceptant un nombre variable d'arguments. C'est particulièrement adapté aux fonctions de nature mathématique ou agrégative (ex. concaténation, combinaison en un résultat unique).
  • Extraire les premiers éléments d'un itérable (généralement pour leur appliquer un traitement spécifique) et conserver le reliquat d'un seul tenant.

Imaginons une fonction qui réalise la moyenne de ses arguments, quel qu'en soit le nombre. On peut évidemment utiliser une simple boucle, auquel cas le bon vieil arguments des fonctions traditionnelles pourrait suffire :

// Tellement 1995… 😭
function oldAverage() {
  var sum = 0
  for (var index = 0, len = arguments.length; index < len; ++index) {
    sum += arguments[index]
  }
  return sum / arguments.length
}

Mais ça pourrait être plus sympa de recourir à la méthode reduce() de Array. Sauf que arguments n'est pas un Array1, pas de chance. Qui plus est, la signature de la fonction n'indique en rien qu'on accepte un nombre quelconque d'arguments. Essayons avec le rest au sein de la signature :

function newAverage(...values) {
  return values.reduce((acc, n) => acc + n) / values.length
}

(1 : c'est une instance de SaloperieInutileDeMerdeArguments).

Il faut bien comprendre qu'avant, faire ça en mode tableau, c'était chaud 🔥 :

function oldNewAverage() {
  // 🤯😩
  var values = Array.prototype.slice.call(arguments)
  return values.reduce((acc, n) => acc + n) / values.length
}

En prime, cette syntaxe reste disponible pour les fonctions fléchées, qui ne disposent en revanche pas de arguments (nous y reviendrons dans l'article qui leur sera prochainement dédié #teaser #abonneToi #bonPourTaPeau).

C’est aussi utilisable dans les déstructurations positionnelles, par exemple ici :

const [first, second, ...others] = ['Alice', 'Bob', 'Claire', 'David']
// first === 'Alice', second === 'Bob', others = ['Claire', 'David']

Un rest positionnel produira toujours un tableau, quitte à ce qu'il soit vide. Aucun cas particulier du genre null ou undefined à gérer. Ça, c'est le côté bisou 😘 / cadeau 🎁 de la conception du truc, car les cas particuliers, c'est la plaie.

Spread positionnel

Au cas où tu l'ignorerais, en anglais, “to spread” signifie « étaler », ce qui donne une indication sur le rôle de l'opération.

Le spread positionnel accepte en opérande n'importe quel itérable et le consomme intégralement : il revient à placer, à cet endroit du code, toutes les valeurs de l'itérable séparées par des virgules. On peut l'utiliser dans un appel de fonction ou dans un littéral tableau.

Pour rappel, beaucoup de trucs (#jargon) sont itérables : les tableaux bien entendu, mais aussi les NodeLists (tu sais, ce que renvoie par exemple document.querySelectorAll()), les Maps, les Sets, et même les Strings (ce qui est super cool).

C'est pratique par exemple lorsqu'on a un tableau des arguments à passer à une fonction, qui les attend de son côté comme arguments individuels. On peut ainsi utiliser push pour faire une sorte de concat modifiant :

const arr1 = [1, 2, 3]
const arr2 = [4, 5, 6]
arr1.concat(arr2) // => [1, 2, 3, 4, 5, 6]
// Soit, mais c'est pas modifiant pour autant :
arr1 // => [1, 2, 3]

// push est modifiant, mais prend les valeurs à ajouter non comme un
// tableau, mais comme arguments individuels (push est variadique) :
arr1.push(...arr2) // => 6 (nouvelle longueur)
arr1 // => [1, 2, 3, 4, 5, 6]

On peut spreader autant de fois qu'on veut, y compris plusieurs fois la même source, puisqu'un spread n'est pas censé modifier sa source.

const arr1 = [1, 2, 3]
const arr2 = [5, 6, 7]
arr1.push(4, ...arr2, 8, ...arr2) // => 11
// => [1, 2, 3, 4, 5, 6, 7, 8, 5, 6, 7]

Le spread positionnel est aussi un bon moyen de dériver un nouveau tableau à partir d'un autre (ou de n'importe quel itérable) :

const arr1 = [1, 2, 3]
[0, ...arr1, 4] // => [0, 1, 2, 3, 4]

Dans la mesure où le spread positionnel opère sur un itérable, lui passer n'importe quel autre opérande, et notamment null ou undefined, lèvera une TypeError.

Le ProTip du jour

J'en vois beaucoup qui se servent du spread positionnel comme unique constituant d'un nouveau littéral tableau pour faire une copie superficielle du tableau, ou pour convertir un itérable en tableau, comme ceci :

// Oui mais bof
const newArray = [...originalArray]

// Toujours bof
const array = [...otherIterable]

Alors oui, ça marche, mais tu devrais viser la clarté en préférant l'API standard Array.from(). Déjà, c'est plus explicite. Mais en prime, tu peux passer une fonction de conversion à la volée, qui t'évitera une deuxième passe juste pour ça :

// #PasLuLaDoc #RockStarDeDaube #DoublePasse
const mehWordSizes = [...wordsIterable].map((word) => word.length)

// 😍😎
const yeahWordSizes = Array.from(wordsIterable, (word) => word.length)

Rest sur propriétés (ES2018)

ES2018 a standardisé une proposition appelée Rest/Spread Properties, et c'est cool.

Le rest sur propriétés, parfois appelé rest nominatif, n'est possible qu'au sein d'une déstructuration nominative. Il est similaire conceptuellement à un rest positionnel au sein d'une déstructuration : il va récupérer le reste des paires clé-valeur, celles non explicitement récupérées par la déstructuration.

De la même façon qu'un rest positionnel produira toujours un tableau, quitte à ce qu'il soit vide, un rest sur propriétés produira systématiquement un nouvel objet, quitte à ce qu'il n'ait pas de propriétés propres (un objet « vide », qui conservera toutefois Object comme prototype).

function preprocessConfig(config) {
  const { debug, env, ...mainConfig } = config
  // On utilise ici `debug` et `env`, qui ne concernent que nous.// Puis on passe au traitement du reste de la config :
  processConfig(mainConfig)
}

C'est aussi classique dans les composants d’ordre supérieur en React (Higher-Order Components, ou HOC, qui sont toutefois une approche problématique plutôt dépréciée au profit des hooks depuis React 16.8), du genre :

// Ne faites pas ça, y'a mieux maintenant !
function LoggingWrapper({ log, component: Component, ...delegated }) {
  log.notify('rendering')
  return <Component {...delegated} />
}

Spread sur propriétés (ES2018)

Le spread sur propriétés, parfois appelé spread nominatif, n'est possible qu'au sein d'un littéral objet. Il peut y être utilisé plusieurs fois, y compris sur la même source. Vu qu'il figure dans un littéral objet, il contribue donc toujours à la création d'un nouvel objet, pas à la modification d'un objet existant.

Du coup, ne pas confondre avec Object.assign().

Un spread sur propriétés revient à lister à son endroit du code l'ensemble des paires clé-valeur pour les propriétés propres énumérables de son opérande.

On peut s'en servir pour plein de choses, par exemple fusionner deux objets ou plus en un seul :

const addr1 = { street: '19 rue FM', zip: 92700, city: 'Colombes' }
const addr2 = { zip: 75011, city: 'Paris', country: 'FR' }

const addr = { ...addr1, ...addr2 }
// => { street: '19 rue FM', zip: 75011, city: 'Paris', country: 'FR' }

Ou produire un objet final à partir de propriétés par défaut, de propriétés fournies sur le moment, et de garanties finales éventuelles :

const defaults = { times: 1, separator: '-' }

function normalizeConfig(config) {
  return { ...defaults, ...config, normalizedAt: Date.now() }
}

normalizeConfig({ term: 'yo', times: 4, normalizedAt: 0 })
// => { term: 'yo', times: 4, separator: '-', normalizedAt: 1639… }

C'est aussi utile pour produire un objet dérivé plutôt que d'appliquer une mutation à un objet existant :

function derive(original, newSize) {
  return { ...original, size: newSize }
}

const john = { name: 'John', age: 42, size: 5.8 }
derive(john, 6.1)
// => { name: 'John', age: 42, size: 6.1 }
john
// => { name: 'John', age: 42, size: 5.8 }

Tout comme le spread positionnel, c'est donc particulièrement pratique pour des approches de programmation fonctionnelle, basées sur l'immutabilité et la dérivation de valeurs.

Détail pratique sympa : ça n'explosera pas si on lui passe null ou undefined, mais se contentera de ne rien contribuer.

Ça t’a plu ? Ne manque pas la suite !

Souviens-toi, il reste encore un larron dans la Sainte Trinité ! Mais il y a aussi encore tout plein de sujets merveilleux à venir dans cette série.

Pour être sûr·e de ne rater aucun de nos tutos et articles, le mieux est encore de t’abonner à notre newsletter et à notre chaîne YouTube. Tu peux aussi nous suivre sur Twitter.

Et bien entendu, n'hésite pas à jeter un œil à nos formations ! Si explorer l'intégralité des recoins du langage t’intéresse, la ES Total notamment est faite pour toi !

Découvrez nos cours vidéo ! 🖥

Nos cours vidéo sont un complément idéal et bon marché à nos articles techniques et formations présentielles. Autour de Git, de JavaScript et d’autres sujets, retrouvez des contenus de très grande qualité à des prix abordables, spécialement conçus pour lever vos plus gros points de blocage.