ES2025 Features Every Developer Should Know

ES2025 Features Every Developer Should Know

ES2025 adds the pipeline operator, pattern matching, Records and Tuples, Set methods, the Temporal API, and more. Some of it is genuinely exciting. Some of it you probably won't touch for another two years. Here's an honest look at what's coming.

New JavaScript features. Some of these are actually useful. Some I'm still not sure about. Here's the rundown.

Every year TC39 ships a new batch of proposals, and every year the JavaScript community gets loud about it. ES2025 has a few things worth paying attention to, and a few things that feel like solutions looking for problems. I'll try to be fair. I'll walk through them one by one.

The Pipeline Operator

The pipeline operator (|>) is cool in theory. In practice? Jury's still out.

The pitch is simple: instead of nesting function calls inside-out like some kind of Lisp programmer, you write them left to right. And yeah, when you see the before and after, it does look nicer.

// Without pipeline operator - read inside out
const result = capitalize(trim(sanitize(getUserInput())));

// With pipeline operator - read left to right
const result = getUserInput()
  |> sanitize(%)
  |> trim(%)
  |> capitalize(%);

The % token is the topic reference -- it holds the value from the previous step. TC39 went with the Hack-style pipeline over the F#-style, because it works with any expression, not just unary functions. That's a reasonable choice.

// Works with any expression
const doubled = [1, 2, 3]
  |> %.map(n => n * 2)
  |> %.filter(n => n > 2)
  |> JSON.stringify(%);

// Combine with await
const userData = userId
  |> await fetchUser(%)
  |> %.profile
  |> formatProfile(%);

// Chain arithmetic operations
const fahrenheit = celsius
  |> % * 9
  |> % / 5
  |> % + 32;

Here's my issue, though: most real-world code doesn't have five-step pipelines. You'll use this for two or three steps, and at that point, a couple of intermediate variables are just as readable. Maybe more readable, because everyone on your team already knows how variables work.

I'm not against it. I just think the hype outpaces the actual day-to-day value. We'll see how adoption goes.

Record and Tuple: Immutable Data Structures

This one I'm actually excited about. JavaScript has never had real immutable data structures. Sure, you can Object.freeze things, but that's shallow and kind of a hack. Libraries like Immutable.js solve the problem but bloat your bundle.

Records and Tuples are deeply immutable primitives, prefixed with #.

// Tuples - immutable arrays
const point = #[10, 20, 30];
point[0]; // 10
point.push(40); // TypeError: push is not a function

// Records - immutable objects
const user = #{
  name: "Anurag",
  age: 28,
  role: "developer"
};
user.name; // "Anurag"
user.name = "Bob"; // TypeError in strict mode

// Deep immutability
const nested = #{
  coords: #[1, 2],
  meta: #{ label: "origin" }
};

But the real win is equality by value. Two plain objects with the same keys and values? Not equal, because JavaScript compares by reference. Two Records with the same contents? Equal. Finally.

// Regular objects - reference equality
{ a: 1 } === { a: 1 }; // false
[1, 2] === [1, 2];     // false

// Records and Tuples - value equality
#{ a: 1 } === #{ a: 1 }; // true
#[1, 2] === #[1, 2];     // true

// This makes them perfect as Map keys
const cache = new Map();
cache.set(#{ x: 10, y: 20 }, "cached value");
cache.get(#{ x: 10, y: 20 }); // "cached value" - works!

One constraint: Records and Tuples can only contain primitives and other Records/Tuples. No nesting regular objects inside them. That's the trade-off that makes value equality work without expensive deep comparisons. Fair enough.

Pattern Matching

If you've used Rust or Elixir, you know how good pattern matching can be. JavaScript's version uses a match expression that makes switch statements feel like stone tablets.

// Basic pattern matching
const describe = (value) => match (value) {
  when (0) -> "zero",
  when (1) -> "one",
  when (Number) -> `number: ${value}`,
  when (String) -> `string: ${value}`,
  when (_) -> "unknown"
};

describe(42);     // "number: 42"
describe("hello"); // "string: hello"

Where it gets interesting is destructuring and guards.

// Destructuring patterns
const processResponse = (response) => match (response) {
  when ({ status: 200, data }) -> renderData(data),
  when ({ status: 404 }) -> showNotFound(),
  when ({ status: 500, error }) -> logError(error),
  when ({ status }) if (status >= 300 && status < 400) -> handleRedirect(response),
  when (_) -> showGenericError()
};

// Array patterns
const first = (arr) => match (arr) {
  when ([]) -> null,
  when ([x]) -> x,
  when ([x, ...rest]) -> x
};

// Nested patterns
const processEvent = (event) => match (event) {
  when ({ type: "click", target: { id: "submit" } }) -> handleSubmit(),
  when ({ type: "click", target: { id: "cancel" } }) -> handleCancel(),
  when ({ type: "keydown", key: "Enter" }) -> handleEnter(),
  when ({ type: "keydown", key: "Escape" }) -> handleEscape(),
  when (_) -> null
};

It's an expression, so it returns a value. The guard clauses (if after when) let you add runtime checks to any branch. Try doing that cleanly with switch -- you can't, not really.

I'll admit, this is one of the features I'm most looking forward to. The switch statement is genuinely bad, and this is a proper replacement.

The Temporal API

Every JavaScript developer has a Date horror story. Mine involves time zone conversions, a production bug at 2 AM, and the realization that getMonth() is zero-indexed but getDate() isn't. Good times.

Temporal is a ground-up replacement for Date. Immutable types. Proper time zone handling. No more guessing whether something is UTC or local.

// Current date and time
const now = Temporal.Now.zonedDateTimeISO();
console.log(now.toString());
// 2026-02-21T14:30:00+05:30[Asia/Kolkata]

// Plain date (no time, no time zone)
const date = Temporal.PlainDate.from('2026-02-21');
console.log(date.year);  // 2026
console.log(date.month); // 2
console.log(date.day);   // 21

// Plain time (no date, no time zone)
const time = Temporal.PlainTime.from('14:30:00');

// Date arithmetic is immutable
const nextWeek = date.add({ days: 7 });
console.log(nextWeek.toString()); // 2026-02-28
console.log(date.toString());     // 2026-02-21 (unchanged)

Time zone conversions that used to take a library and a prayer now just... work.

// Time zone conversions
const meeting = Temporal.ZonedDateTime.from({
  year: 2026,
  month: 3,
  day: 15,
  hour: 10,
  minute: 0,
  timeZone: 'America/New_York'
});

// Convert to another time zone
const inIndia = meeting.withTimeZone('Asia/Kolkata');
console.log(inIndia.hour); // 19 (7:00 PM IST)

// Duration
const duration = Temporal.Duration.from({ hours: 2, minutes: 30 });
const endTime = meeting.add(duration);

// Comparison
const d1 = Temporal.PlainDate.from('2026-06-15');
const d2 = Temporal.PlainDate.from('2026-12-25');
console.log(Temporal.PlainDate.compare(d1, d2)); // -1 (d1 is earlier)

// Difference between dates
const diff = d1.until(d2);
console.log(diff.toString()); // P193D (193 days)

Temporal splits dates and times into distinct types: PlainDate, PlainTime, PlainDateTime, ZonedDateTime, Instant, Duration, and more. The type itself tells you what information it carries. No more "is this UTC or local?" -- the type answers that question for you. This alone would justify the proposal.

Iterator Helpers and Array.fromAsync

Iterator helpers add map, filter, take, drop, flatMap, reduce, toArray, and forEach directly to iterator prototypes. Think of it as array methods, but lazy and available on any iterable -- generators, Map entries, Set values, whatever.

// Generator with iterator helpers
function* fibonacci() {
  let a = 0, b = 1;
  while (true) {
    yield a;
    [a, b] = [b, a + b];
  }
}

// Get first 10 even Fibonacci numbers
const evenFibs = fibonacci()
  .filter(n => n % 2 === 0)
  .take(10)
  .toArray();

console.log(evenFibs);
// [0, 2, 8, 34, 144, 610, 2584, 10946, 46368, 196418]

// Process Map entries lazily
const scores = new Map([
  ['alice', 95],
  ['bob', 82],
  ['charlie', 91],
  ['diana', 88]
]);

const topScorers = scores.entries()
  .filter(([_, score]) => score >= 90)
  .map(([name, score]) => `${name}: ${score}`)
  .toArray();

console.log(topScorers); // ['alice: 95', 'charlie: 91']

The big deal here is laziness. Array methods create a new array at every step. Iterator helpers process elements one at a time, which saves memory when you're working with large data sets or infinite sequences like that fibonacci generator above.

Array.fromAsync is the async version of Array.from. Straightforward, but you'd be surprised how often you need it.

// From an async generator
async function* fetchPages(urls) {
  for (const url of urls) {
    const response = await fetch(url);
    yield await response.json();
  }
}

const urls = [
  '/api/page/1',
  '/api/page/2',
  '/api/page/3'
];

const allPages = await Array.fromAsync(fetchPages(urls));
console.log(allPages); // [page1Data, page2Data, page3Data]

// From an iterable of promises
const results = await Array.fromAsync([
  fetch('/api/users').then(r => r.json()),
  fetch('/api/posts').then(r => r.json()),
  fetch('/api/comments').then(r => r.json())
]);

Set Methods: Union, Intersection, and Difference

This one is long overdue. Sets shipped in ES6 and for years the only way to union two of them was new Set([...a, ...b]). Intersection? Manual filtering. It was embarrassing, frankly.

ES2025 adds real set algebra: union, intersection, difference, symmetricDifference, isSubsetOf, isSupersetOf, and isDisjointFrom.

const frontEnd = new Set(['javascript', 'typescript', 'css', 'html', 'react']);
const backEnd = new Set(['javascript', 'typescript', 'python', 'go', 'rust']);

// Union - all unique elements from both sets
const allSkills = frontEnd.union(backEnd);
// Set {'javascript', 'typescript', 'css', 'html', 'react', 'python', 'go', 'rust'}

// Intersection - elements in both sets
const shared = frontEnd.intersection(backEnd);
// Set {'javascript', 'typescript'}

// Difference - elements in first but not second
const frontOnly = frontEnd.difference(backEnd);
// Set {'css', 'html', 'react'}

// Symmetric difference - elements in either but not both
const exclusive = frontEnd.symmetricDifference(backEnd);
// Set {'css', 'html', 'react', 'python', 'go', 'rust'}

// Subset check
const jsTs = new Set(['javascript', 'typescript']);
console.log(jsTs.isSubsetOf(frontEnd));   // true
console.log(frontEnd.isSupersetOf(jsTs)); // true

// Disjoint check - no elements in common
const styles = new Set(['css', 'sass', 'less']);
const systems = new Set(['rust', 'c', 'zig']);
console.log(styles.isDisjointFrom(systems)); // true

All of these return new Sets. None of them mutate the original. They also accept any iterable as an argument, not just other Sets -- so you can pass arrays, generators, or Map keys directly. This is exactly how Set should have worked from day one.

Import Attributes

Import attributes let you attach metadata to import statements. The main use case right now is importing JSON and CSS modules without a bundler doing magic behind the scenes.

// Import JSON as a module
import config from './config.json' with { type: 'json' };
console.log(config.apiUrl);

// Import CSS as a module
import styles from './component.css' with { type: 'css' };
document.adoptedStyleSheets = [styles];

// Dynamic import with attributes
const translations = await import(
  `./locales/${lang}.json`,
  { with: { type: 'json' } }
);

// Re-export with attributes
export { default as schema } from './schema.json' with { type: 'json' };

The with keyword replaced the earlier assert keyword. The difference matters for security: attributes can influence how a module is loaded, not just verify its type after the fact. The runtime can use the type attribute to pick the right evaluation strategy before it even fetches the resource.

Honestly, this is one of those features that's more important for the platform than for individual developers. If you're using a bundler, you already have JSON imports. But for standards-based module loading (and for Deno/Bun users), this fills a real gap.

A Quick Combined Example

Here's a quick example combining several of these features. It's a bit contrived, but it gives you a sense of how they fit together.

import userData from './users.json' with { type: 'json' };

// Use Set methods to find active premium users
const allUsers = new Set(userData.map(u => u.id));
const premiumUsers = new Set(userData.filter(u => u.tier === 'premium').map(u => u.id));
const activeUsers = new Set(userData.filter(u => u.lastLogin > '2026-01-01').map(u => u.id));

const activePremium = premiumUsers.intersection(activeUsers);

// Use iterator helpers to process them
const emailList = userData.values()
  .filter(u => activePremium.has(u.id))
  .map(u => u.email)
  .toArray();

// Use Temporal for date handling
const renewalDate = Temporal.Now.plainDateISO().add({ months: 1 });

// Use pipeline for transformation
const report = emailList
  |> %.join('\n')
  |> `Renewal notices for ${renewalDate}:\n${%}`
  |> %.toUpperCase();

The language keeps changing. Most of this won't affect your daily code for another year or two. But it's good to know what's coming.

Check TC39 proposal stages and your target runtime's support before using any of this in production. Babel and TypeScript will probably ship transforms for most of these ahead of native engine support. In the meantime, play with them in side projects. That's the best way to figure out which features actually matter to you and which are just spec-reading fodder.

Written by Anurag Kumar

Full-stack developer passionate about Node.js and building fast, scalable web applications. Writing about what I learn every day.

Comments (0)

No comments yet. Be the first to share your thoughts!