Skip to content

codingjoe/esupgrade

esupgrade: Auto-upgrade your JavaScript syntax

esupgrade npm version coverage status license

Keeping your JavaScript and TypeScript code up to date with full browser compatibility.

Sponsors

Sponsors

Usage

esupgrade is safe and meant to be used automatically on your codebase. We recommend integrating it into your development workflow using pre-commit or husky.

To try it out on a repository without writing changes, run:

npx esupgrade $(git ls-files | grep -E -i -w '.*\.(t|j)sx?')

pre-commit

uvx pre-commit install
# .pre-commit-config.yaml
repos:
  - repo: https://github.com/codingjoe/esupgrade
    rev: 2025.0.2 # Use the latest version
    hooks:
      - id: esupgrade
pre-commit run esupgrade --all-files

Husky

Assuming Husky is already initialized and .husky/pre-commit already contains set -e, append:

echo "git diff --cached --name-only --diff-filter=ACMR -z -- '*.js' '*.jsx' '*.ts' '*.tsx' '*.mjs' '*.cjs' | xargs -0 sh -c 'test \"\$#\" -eq 0 && exit 0; npx esupgrade -- \"\$@\"' sh" >> .husky/pre-commit
echo "git diff --cached --name-only --diff-filter=ACMR -z -- '*.js' '*.jsx' '*.ts' '*.tsx' '*.mjs' '*.cjs' | xargs -0 sh -c 'test \"\$#\" -eq 0 && exit 0; git add -- \"\$@\"' sh" >> .husky/pre-commit

CLI

npx esupgrade --help
Baseline: widely available

Browser Support & Baseline

All transformations are based on Web Platform Baseline features. Baseline tracks which web platform features are safe to use across browsers.

By default, esupgrade uses widely available features, meaning they work in all major browsers (Chrome, Edge, Safari, Firefox) for at least 30 months. This ensures full compatibility while keeping your code modern.

You can opt into newly available features (available in all browsers for 0-30 months) with:

npx esupgrade --baseline newly-available <files>

For more information about Baseline browser support, visit web.dev/baseline.

Supported File Types & Languages

  • .js - JavaScript
  • .jsx - React/JSX
  • .ts - TypeScript
  • .tsx - TypeScript with JSX
  • .mjs - ES Modules
  • .cjs - CommonJS

Transformations

Baseline: widely available

Widely available

varconst & let

-var x = 1;
-var y = 2;
-y = 3;
+const x = 1;
+let y = 2;
+y = 3;

String concatenation → Template literals

-const greeting = 'Hello ' + name + '!';
-const message = 'You have ' + count + ' items';
+const greeting = `Hello ${name}!`;
+const message = `You have ${count} items`;

Special handling for escape sequences and formatting:

  • Escape sequences: \r (carriage return) is preserved, while \n (newline) is converted to actual newlines

    -const text = "Line 1\n" + "Line 2";
    +const text = `Line 1
    +Line 2`;
  • Multiline concatenation: Visual structure is preserved with line continuation backslashes

    -const longText = "First part " +
    -                 "second part";
    +const longText = `First part \
    +second part`;

Traditional for loops → for...of loops

-for (let i = 0; i < items.length; i++) {
-  const item = items[i];
-  console.log(item);
-}
+for (const item of items) {
+  console.log(item);
+}

Transformations are limited to loops that start at 0, increment by 1, and where the index variable is not used in the loop body.

Array.from().forEach()for...of loops

-Array.from(items).forEach(item => {
-  console.log(item);
-});
+for (const item of items) {
+  console.log(item);
+}

DOM forEach()for...of loops

-document.querySelectorAll('.item').forEach(item => {
-  item.classList.add('active');
-});
+for (const item of document.querySelectorAll('.item')) {
+  item.classList.add('active');
+}

Supports:

  • document.querySelectorAll()

  • document.getElementsByTagName()

  • document.getElementsByClassName()

  • document.getElementsByName()

  • window.frames

  • Transformations limited to inline arrow or function expressions with block statement bodies. Callbacks with index parameters or expression bodies are not transformed.

Array.from()Array spread [...]

-const doubled = Array.from(numbers).map(n => n * 2);
-const filtered = Array.from(items).filter(x => x > 5);
-const arr = Array.from(iterable);
+const doubled = [...numbers].map(n => n * 2);
+const filtered = [...items].filter(x => x > 5);
+const arr = [...iterable];

Array.from() with a mapping function or thisArg is not converted.

Object.assign({}, ...)Object spread {...}

-const obj = Object.assign({}, obj1, obj2);
-const copy = Object.assign({}, original);
+const obj = { ...obj1, ...obj2 };
+const copy = { ...original };

Note

TypeScript does not support generic object spread yet: microsoft/TypeScript#10727 You might need to manually adjust the type after transformation:

const object_with_generic_type: object = { ...(myGenericObject as object) }

Array.concat()Array spread [...]

-const combined = arr1.concat(arr2, arr3);
-const withItem = array.concat([item]);
+const combined = [...arr1, ...arr2, ...arr3];
+const withItem = [...array, item];

1000000Numeric separators

-const budget = 1000000;
-const maxUsers = 1000000n;
+const budget = 1_000_000;
+const maxUsers = 1_000_000n;

esupgrade transforms decimal integer and bigint literals with at least five digits. Fractional, exponential, hexadecimal, octal, binary, and already formatted literals are left unchanged.

Array.slice(0)Array spread [...]

-const copy = [1, 2, 3].slice(0);
-const clone = Array.from(items).slice();
+const copy = [...[1, 2, 3]];
+const clone = [...Array.from(items)];

Array.filter()[0]Array.find()

-const first = [1, 2, 3].filter(n => n > 1)[0];
+const first = [1, 2, 3].find(n => n > 1);

Transformations are limited to when the receiver can be verified as an array (array literals, new Array(), or known array method chains) and filter() is called with one argument.

arr[arr.length - n]Array.at()

-const last = arr[arr.length - 1];
-const item = arr[arr.length - n];
+const last = arr.at(-1);
+const item = arr.at(-n);
-const result = Math.pow(2, 3);
-const area = Math.PI * Math.pow(radius, 2);
+const result = 2 ** 3;
+const area = Math.PI * radius ** 2;

Verbose arithmetic assignments → Compound assignment operators (+=, -=, …)

-x = x + y
+x += y
-x = x - y
+x -= y
-x = x * y
+x *= y
-x = x / y
+x /= y
-x = x % y
+x %= y
-x = x ** y
+x **= y

Note

When the assignment target is a member expression with potential side effects (e.g., getObj().prop = getObj().prop + y or obj[f()] = obj[f()] + y), the transformation evaluates the target expression once instead of twice. If getObj() or f() have observable side effects or return different values on each call, getObj().prop += y may produce different results than the original getObj().prop = getObj().prop + y.

Named function assignments → Function declarations

-const myFunc = () => { return 42; };
-const add = (a, b) => a + b;
-const greet = function(name) { return "Hello " + name; };
+function myFunc() { return 42; }
+function add(a, b) { return a + b; }
+function greet(name) { return "Hello " + name; }

Transforms arrow functions and anonymous function expressions assigned to variables into proper named function declarations. This provides better structure and semantics for top-level functions.

Functions using this or arguments are not converted to preserve semantics.

TypeScript parameter and return type annotations are preserved:

-let myAdd = function (x: number, y: number): number {
-  return x + y;
-};
+function myAdd(x: number, y: number): number {
+  return x + y;
+}

Generic type parameters are also preserved:

-export const useHook = <T extends object>(props: T): T => {
-  return props;
-};
+export function useHook<T extends object>(props: T): T {
+  return props;
+}

Variables with TypeScript type annotations but no function return type are skipped:

// Not transformed - variable type annotation cannot be transferred
const Template: StoryFn<MyType> = () => { return <div>Hello</div>; };

Anonymous function expressions → Arrow functions

-items.map(function(item) { return item.name; });
-button.addEventListener('click', function(event) { process(event); });
+items.map(item => { return item.name; });
+button.addEventListener('click', event => { process(event); });

Anonymous function expressions not in variable declarations (like callbacks and event handlers) are converted to arrow functions.

Functions using this, arguments, or super are not converted to preserve semantics.

Constructor functions → Classes

-function Person(name, age) {
-  this.name = name;
-  this.age = age;
-}
-
-Person.prototype.greet = function() {
-  return 'Hello, I am ' + this.name;
-};
-
-Person.prototype.getAge = function() {
-  return this.age;
-};
+class Person {
+  constructor(name, age) {
+    this.name = name;
+    this.age = age;
+  }
+
+  greet() {
+    return 'Hello, I am ' + this.name;
+  }
+
+  getAge() {
+    return this.age;
+  }
+}

Transforms constructor functions (both function declarations and variable declarations) that meet these criteria:

  • Function name starts with an uppercase letter
  • At least one prototype method is defined
  • Prototype methods are matched only to constructors declared in the current or an ancestor lexical scope (never sibling or child scopes)
  • Prototype methods using this in arrow functions are skipped
  • Prototype object literals with getters, setters, or computed properties are skipped

console.log()console.info()

-console.log('User logged in:', username);
-console.log({ userId, action: 'login' });
+console.info('User logged in:', username);
+console.info({ userId, action: 'login' });

While console.log and console.info are functionally identical in browsers. This transformation provides semantic clarity by using an explicit log level, but review your logging infrastructure before applying.

Remove redundant 'use strict' from modules

-'use strict';
 import { helper } from './utils';

 export function main() {
   return helper();
 }

ES6 modules are automatically in strict mode, making explicit 'use strict' directives redundant. This transformation applies to files with import or export statements.

Global context → globalThis

-const global = window;
-const loc = window.location.href;
+const global = globalThis;
+const loc = globalThis.location.href;
-const global = self;
-const nav = self.navigator;
+const global = globalThis;
+const nav = globalThis.navigator;
-const global = Function('return this')();
+const global = globalThis;

Null/undefined checks → Nullish coalescing operator (??)

-const value = x !== null && x !== undefined ? x : defaultValue;
+const value = x ?? defaultValue;
-const result = obj.prop !== null && obj.prop !== undefined ? obj.prop : 0;
+const result = obj.prop ?? 0;

Logical assignment patterns → Logical assignment operators (??=, ||=, &&=)

-x = x ?? y
+x ??= y
-x = x || y
+x ||= y
-x = x && y
+x &&= y
-if (x === null || x === undefined) x = y
+x ??= y

Note

For member expression targets (e.g., obj.prop ||= y), the original unconditional assignment always invokes property setters, while the logical assignment operator skips the setter when the condition is not met. This generally improves performance by avoiding unnecessary setter calls.

Similarly, for if (obj.prop === null || obj.prop === undefined) obj.prop = y, the transformation to obj.prop ??= y reads obj.prop once instead of twice before assigning, which improves performance for getter-backed properties.

indexOf()includes()

-const found = [1, 2, 3].indexOf(item) !== -1;
-const exists = "hello".indexOf(substr) > -1;
-const hasValue = ["a", "b", "c"].indexOf(value) >= 0;
+const found = [1, 2, 3].includes(item);
+const exists = "hello".includes(substr);
+const hasValue = ["a", "b", "c"].includes(value);
-if ([1, 2, 3].indexOf(item) === -1) {
-  console.log('not found');
-}
+if (![1, 2, 3].includes(item)) {
+  console.log('not found');
+}

Transforms indexOf() calls with a single argument (search value) when it can statically verify that the receiver is an array or string (for example, array literals, string literals, or safe method chains). Calls with a fromIndex parameter are not transformed as they have different semantics than includes(). As a result, patterns such as [1, 2, 3].indexOf(item) !== -1 are upgraded, while arr.indexOf(item) !== -1 may be left unchanged if the transformer cannot prove that arr is an array.

String.substr()String.slice()

-const result = "hello world".substr(0, 5);
-const end = "example".substr(3);
+const result = "hello world".slice(0, 0 + 5);
+const end = "example".slice(3);

Transforms the deprecated substr() method to slice():

  • str.substr(start, length) becomes str.slice(start, start + length)
  • str.substr(start) becomes str.slice(start)
  • str.substr() becomes str.slice()

Transformations are limited to when the receiver can be verified as a string (string literals, template literals, or string method chains).

split().join() / replace(/literal/g)String.replaceAll()

-const value = "a,b,c".split(",").join(".");
-const label = "foo".replace(/o/g, "a");
+const value = "a,b,c".replaceAll(",", ".");
+const label = "foo".replaceAll("o", "a");

Object.keys().forEach()Object.entries()

-Object.keys(obj).forEach(key => {
-  const value = obj[key];
-  console.log(key, value);
-});
+Object.entries(obj).forEach(([key, value]) => {
+  console.log(key, value);
+});

Transforms when:

  • The callback has one parameter (the key)
  • The first statement in the callback assigns obj[key] to a variable
  • The object being accessed matches the object passed to Object.keys()

Object.keys().map()Object.values()

-Object.keys(obj).map(key => obj[key])
+Object.values(obj)

Transforms when:

  • The callback has one parameter (the key)
  • The callback body returns obj[key] (expression or block with a single return statement)
  • The object being accessed matches the object passed to Object.keys()

indexOf() prefix check → String.startsWith()

-const isPrefix = "hello world".indexOf("hello") === 0;
-const notPrefix = str.indexOf(prefix) !== 0;
+const isPrefix = "hello world".startsWith("hello");
+const notPrefix = !str.startsWith(prefix);

Transforms indexOf() prefix checks to the more explicit startsWith() method. Transforms when the receiver can be verified as a string and indexOf() is compared to 0.

substring() prefix check → String.startsWith()

-const matches = "hello world".substring(0, prefix.length) === prefix;
-const noMatch = str.substring(0, prefix.length) !== prefix;
+const matches = "hello world".startsWith(prefix);
+const noMatch = !str.startsWith(prefix);

Transforms substring() prefix comparisons to startsWith(). Transforms patterns where substring(0, prefix.length) is compared to prefix.

lastIndexOf() suffix check → String.endsWith()

-const isSuffix = str.lastIndexOf(suffix) === str.length - suffix.length;
-const notSuffix = "hello world".lastIndexOf("world") !== "hello world".length - "world".length;
+const isSuffix = str.endsWith(suffix);
+const notSuffix = !"hello world".endsWith("world");

Transforms lastIndexOf() suffix checks to the more explicit endsWith() method. Transforms when the receiver can be verified as a string and the pattern matches lastIndexOf(suffix) === str.length - suffix.length.

arguments object → Rest parameters ...

-function fn() {
-  const args = Array.from(arguments);
-  // use args
-}
+function fn(...args) {
+  // use args
+}
-function fn() {
-  const args = [].slice.call(arguments);
-  // use args
-}
+function fn(...args) {
+  // use args
+}

Transforms the arguments object to rest parameters when:

  • Function is a regular function (not arrow function)
  • Function doesn't already have rest parameters
  • arguments is used in the conversion pattern (Array.from(arguments) or [].slice.call(arguments))
  • arguments is not used elsewhere in the function

The transformer handles cases where Array.from(arguments) has already been converted to [...arguments] by other transformers.

Manual default values → Default parameters

-function fn(x) {
-  if (x === undefined) x = defaultValue;
-  // use x
-}
+function fn(x = defaultValue) {
+  // use x
+}

Note: The x = x || defaultValue pattern is NOT transformed as it has different semantics (triggers on any falsy value, instead of undefined).

Manual property extraction → Destructuring parameters

-function fn(obj) {
-  const x = obj.x;
-  const y = obj.y;
-  // use x and y
-}
+function fn({x, y}) {
+  // use x and y
+}

Transforms functions where the body begins by extracting properties from a parameter into object destructuring in the parameter list. Aliased extractions use longhand syntax:

-function fn(obj) {
-  const myX = obj.x;
-}
+function fn({x: myX}) {
+}

Transforms when:

  • The parameter is a simple identifier (not already destructured or a rest parameter)
  • Leading statements are variable declarations extracting non-computed properties from that parameter
  • The original parameter identifier is not referenced after the extraction zone

TypeScript type annotations on the original parameter are preserved on the resulting destructuring pattern.

Promise chains → async/await

-function getData() {
-  return fetch('/api/data')
-    .then(result => {
-      // handle result
-    })
-    .catch(err => {
-      // handle error
-    });
-}
+async function getData() {
+  try {
+    const result = await fetch('/api/data');
+    // handle result
+  } catch (err) {
+    // handle error
+  }
+}
  • The promise chain is returned from the function or used inside an already async function
  • The expression is a known promise (fetch(), new Promise(), or promise methods)
Baseline: Newly available

Newly available

These transformations are mainly to harden code for future releases and should be used with caution.

new Promise((resolve) => { ... })Promise.try

-new Promise((resolve) => {
-  const result = doSomething();
-  resolve(result);
-});
+Promise.try(() => {
+  return doSomething();
+});

Object.prototype.hasOwnProperty.call()Object.hasOwn()

-Object.prototype.hasOwnProperty.call(obj, prop);
-({}).hasOwnProperty.call(obj, prop);
+Object.hasOwn(obj, prop);
+Object.hasOwn(obj, prop);

Versioning

esupgrade uses the calver YYYY.MINOR.PATCH versioning scheme.

The year indicates the baseline version. New transformations are added in minor releases, while patches are reserved for bug fixes.

Related Projects

Thanks to these projects for inspiring esupgrade:

Distinction

lebab is a similar project that focuses on ECMAScript 6+ transformations without considering browser support. esupgrade is distinct in that it applies transformations that are safe based on Baseline browser support. Furthermore, esupgrade supports JavaScript, TypeScript, and more, while lebab is limited to JavaScript.