After 20 years of writing JavaScript, I've seen many changes - from callback hell to async/await. But the upcoming JavaScript features will transform how we write code completely.
We've tested these proposals using transpilers and polyfills, ans the results are impressive: code that took 30 lines now takes 10, complex logic becomes readable at a glance, and even junior developers (and Devin 😅) can understand complex parts of our codebase.
If you've read my previous articles on HTML5 elements you didn't know you need or CSS modal windows, you know we like unusual tech at Lingo.dev. These upcoming JavaScript features solve real problems that have annoyed developers for years.
Pipeline Operator Improves Code Readability
Complex data transformations in JavaScript often result in deeply nested function calls that are difficult to read and maintain. Developers must trace through nested functions from inside out, jumping between parentheses to understand the flow of data.
The pipeline operator (|>
) solves this problem by allowing data to flow through a series of operations in a clear, top-to-bottom manner:
// Instead of this nested mess
const result = saveToDatabase(
validateUser(
normalizeData(
enrichUserProfile(user)
)
)
);
// You'll write this
const result = user
|> enrichUserProfile
|> normalizeData
|> validateUser
|> saveToDatabase;
Let's look at a more complex real-world example. Consider an image processing service that has grown over time:
// Before: Nested function hell
function processImage(image) {
return compressImage(
addWatermark(
optimizeForWeb(
applyFilter(
resizeImage(image, { width: 800, height: 600 }),
'sepia'
)
),
'Copyright 2025'
),
0.8
);
}
// After: Clean, readable pipeline
function processImage(image) {
return image
|> (img) => resizeImage(img, { width: 800, height: 600 })
|> (img) => applyFilter(img, 'sepia')
|> optimizeForWeb
|> (img) => addWatermark(img, 'Copyright 2025')
|> (img) => compressImage(img, 0.8);
}
Here's another practical example showing how the pipeline operator simplifies data processing for analytics:
// Before: Hard to follow the data flow
function analyzeUserData(users) {
return generateReport(
groupByMetric(
filterInactiveUsers(
normalizeUserData(users)
),
'registrationMonth'
)
);
}
// After: Clear data transformation steps
function analyzeUserData(users) {
return users
|> normalizeUserData
|> filterInactiveUsers
|> (data) => groupByMetric(data, 'registrationMonth')
|> generateReport;
}
The pipeline operator also makes it easier to insert debugging or logging between steps:
function processPayment(payment) {
return payment
|> validatePayment
|> (result) => {
console.log(`Validation result: ${JSON.stringify(result)}`);
return result;
}
|> processTransaction
|> (result) => {
console.log(`Transaction result: ${JSON.stringify(result)}`);
return result;
}
|> sendReceipt;
}
How to Use It Today
The pipeline operator is currently at Stage 2 in the TC39 process as of May 2025. While not yet part of the official JavaScript specification, you can start using it today:
With Babel (Generic Setup):
# Install the pipeline operator plugin
npm install --save-dev @babel/plugin-proposal-pipeline-operator
Add to your .babelrc
:
{
"plugins": [
["@babel/plugin-proposal-pipeline-operator", { "proposal": "hack", "topicToken": "%"}]
]
}
With Vite:
# Install dependencies
npm install --save-dev vite-plugin-babel @babel/core @babel/plugin-proposal-pipeline-operator
// vite.config.js
import { defineConfig } from 'vite';
import babel from 'vite-plugin-babel';
export default defineConfig({
plugins: [
babel({
babelConfig: {
plugins: [
['@babel/plugin-proposal-pipeline-operator', { proposal: 'hack', topicToken: '%' }]
]
}
})
]
});
With Next.js:
# Install the pipeline operator plugin
npm install --save-dev @babel/plugin-proposal-pipeline-operator
// .babelrc
{
"presets": ["next/babel"],
"plugins": [
["@babel/plugin-proposal-pipeline-operator", { "proposal": "hack", "topicToken": "%" }]
]
}
With tsup:
# Install dependencies
npm install --save-dev tsup @babel/core @babel/plugin-proposal-pipeline-operator
// tsup.config.ts
import { defineConfig } from 'tsup';
import * as babel from '@babel/core';
import fs from 'fs';
export default defineConfig({
entry: ['src/index.ts'],
format: ['cjs', 'esm'],
dts: true,
esbuildPlugins: [
{
name: 'babel',
setup(build) {
build.onLoad({ filter: /\.(jsx?|tsx?)$/ }, async (args) => {
const source = await fs.promises.readFile(args.path, 'utf8');
const result = await babel.transformAsync(source, {
filename: args.path,
presets: [
['@babel/preset-env', { targets: 'defaults' }],
'@babel/preset-typescript'
],
plugins: [
['@babel/plugin-proposal-pipeline-operator', { proposal: 'hack', topicToken: '%' }]
]
});
return {
contents: result?.code || '',
loader: args.path.endsWith('x') ? 'jsx' : 'js'
};
});
}
}
]
});
With Remix (using Vite):
# Install dependencies
npm install --save-dev vite-plugin-babel @babel/core @babel/plugin-proposal-pipeline-operator
// vite.config.js
import { defineConfig } from 'vite';
import { vitePlugin as remix } from '@remix-run/dev';
import babel from 'vite-plugin-babel';
export default defineConfig({
plugins: [
babel({
babelConfig: {
plugins: [
['@babel/plugin-proposal-pipeline-operator', { proposal: 'hack', topicToken: '%' }]
]
}
}),
remix()
]
});
Pattern Matching Simplifies Complex Conditionals
Complex if/else statements and switch cases quickly become unwieldy in large codebases. Functions with nested if/else blocks checking various object properties make it nearly impossible to verify all edge cases.
Pattern matching provides a direct solution to this problem.
This feature brings functional programming capabilities to JavaScript, allowing you to match and destructure complex data in a single operation:
function processMessage(message) {
return match (message) {
when String(text) => `Text message: ${text}`,
when [String(sender), String(content)] => `Message from ${sender}: ${content}`,
when { type: 'error', content: String(text), code: Number(code) } =>
`Error: ${text} (Code: ${code})`,
when _ => 'Unknown message format'
};
}
Without pattern matching, you'd need multiple if/else statements with type checking:
function processMessage(message) {
// Check if it's a string
if (typeof message === 'string') {
return `Text message: ${message}`;
}
// Check if it's an array with specific structure
if (Array.isArray(message) &&
message.length === 2 &&
typeof message[0] === 'string' &&
typeof message[1] === 'string') {
return `Message from ${message[0]}: ${message[1]}`;
}
// Check if it's an object with specific properties
if (message &&
typeof message === 'object' &&
message.type === 'error' &&
typeof message.content === 'string' &&
typeof message.code === 'number') {
return `Error: ${message.content} (Code: ${message.code})`;
}
// Default case
return 'Unknown message format';
}
Pattern matching excels when handling complex state transitions in modern web applications:
function handleUserAction(state, action) {
return match ([state, action]) {
when [{ status: 'idle' }, { type: 'FETCH_START' }] =>
({ status: 'loading', data: state.data }),
when [{ status: 'loading' }, { type: 'FETCH_SUCCESS', payload }] =>
({ status: 'success', data: payload, error: null }),
when [{ status: 'loading' }, { type: 'FETCH_ERROR', error }] =>
({ status: 'error', data: null, error }),
when [{ status: 'success' }, { type: 'REFRESH' }] =>
({ status: 'loading', data: state.data }),
when _ => state
};
}
The equivalent code without pattern matching is significantly more verbose:
function handleUserAction(state, action) {
// Check idle state + fetch start
if (state.status === 'idle' && action.type === 'FETCH_START') {
return { status: 'loading', data: state.data };
}
// Check loading state + fetch success
if (state.status === 'loading' && action.type === 'FETCH_SUCCESS') {
return { status: 'success', data: action.payload, error: null };
}
// Check loading state + fetch error
if (state.status === 'loading' && action.type === 'FETCH_ERROR') {
return { status: 'error', data: null, error: action.error };
}
// Check success state + refresh
if (state.status === 'success' && action.type === 'REFRESH') {
return { status: 'loading', data: state.data };
}
// Default: return unchanged state
return state;
}
Pattern matching also provides exhaustiveness checking - the compiler warns you if you've missed handling a possible case. This eliminates an entire class of bugs that plague traditional conditional logic.
Here's another practical example for parsing configuration formats:
function parseConfig(config) {
return match (config) {
when { version: 1, settings: Object(settings) } =>
parseV1Settings(settings),
when { version: 2, config: Object(settings) } =>
parseV2Settings(settings),
when String(jsonString) =>
parseConfig(JSON.parse(jsonString)),
when [String(env), Object(overrides)] =>
mergeConfigs(getEnvConfig(env), overrides),
when _ =>
throw new Error(`Invalid configuration format: ${JSON.stringify(config)}`)
};
}
How to Use It Today
Pattern matching is currently at Stage 1 in the TC39 process as of May 2025, which means it's still in the proposal phase with ongoing discussions about syntax and semantics. However, you can experiment with it today:
With Babel (Generic Setup):
# Install the pattern matching plugin
npm install --save-dev babel-plugin-proposal-pattern-matching
Add to your .babelrc
:
{
"plugins": ["babel-plugin-proposal-pattern-matching"]
}
With Vite:
# Install dependencies
npm install --save-dev vite-plugin-babel @babel/core babel-plugin-proposal-pattern-matching
// vite.config.js
import { defineConfig } from 'vite';
import babel from 'vite-plugin-babel';
export default defineConfig({
plugins: [
babel({
babelConfig: {
plugins: [
'babel-plugin-proposal-pattern-matching'
]
}
})
]
});
With Next.js:
# Install the pattern matching plugin
npm install --save-dev babel-plugin-proposal-pattern-matching
// .babelrc
{
"presets": ["next/babel"],
"plugins": [
"babel-plugin-proposal-pattern-matching"
]
}
With tsup:
# Install dependencies
npm install --save-dev tsup @babel/core babel-plugin-proposal-pattern-matching
// tsup.config.ts
import { defineConfig } from 'tsup';
import * as babel from '@babel/core';
import fs from 'fs';
export default defineConfig({
entry: ['src/index.ts'],
format: ['cjs', 'esm'],
dts: true,
esbuildPlugins: [
{
name: 'babel',
setup(build) {
build.onLoad({ filter: /\.(jsx?|tsx?)$/ }, async (args) => {
const source = await fs.promises.readFile(args.path, 'utf8');
const result = await babel.transformAsync(source, {
filename: args.path,
presets: [
['@babel/preset-env', { targets: 'defaults' }],
'@babel/preset-typescript'
],
plugins: [
'babel-plugin-proposal-pattern-matching'
]
});
return {
contents: result?.code || '',
loader: args.path.endsWith('x') ? 'jsx' : 'js'
};
});
}
}
]
});
Production Alternatives:
For production code today, use the ts-pattern library:
npm install ts-pattern
import { match, P } from 'ts-pattern';
function processMessage(message: unknown) {
return match(message)
.with(P.string, text => `Text message: ${text}`)
.with([P.string, P.string], ([sender, content]) =>
`Message from ${sender}: ${content}`)
.with({ type: 'error', content: P.string, code: P.number },
({ content, code }) => `Error: ${content} (Code: ${code})`)
.otherwise(() => 'Unknown message format');
}
Temporal API Solves Date Handling Problems
JavaScript's built-in Date
object has long been a source of frustration for developers. It's mutable (dates can be accidentally modified), has confusing month indexing (January is 0!), and timezone handling is problematic. These issues have led to the widespread use of libraries like Moment.js, date-fns, and Luxon.
The Temporal API provides a complete solution to these problems by reimagining date and time handling in JavaScript.
Here's a practical example of scheduling a meeting across timezones:
// Current approach with Date (likely to have bugs)
function scheduleMeeting(startDate, durationInMinutes, timeZone) {
const start = new Date(startDate);
const end = new Date(start.getTime() + durationInMinutes * 60000);
return {
start: start.toISOString(),
end: end.toISOString(),
timeZone: timeZone // Not actually used in calculations
};
}
// With Temporal API
function scheduleMeeting(startDateTime, durationInMinutes, timeZone) {
const start = Temporal.ZonedDateTime.from(startDateTime);
const end = start.add({ minutes: durationInMinutes });
return {
start: start.toString(),
end: end.toString(),
timeZone: start.timeZoneId // Properly tracked
};
}
For international flight booking systems, calculating flight durations across timezones becomes straightforward:
// Current approach with Date (error-prone)
function calculateFlightDuration(departure, arrival, departureTimeZone, arrivalTimeZone) {
// Convert to milliseconds and calculate difference
const departureTime = new Date(departure);
const arrivalTime = new Date(arrival);
// This doesn't account for timezone differences correctly
const durationMs = arrivalTime - departureTime;
return {
hours: Math.floor(durationMs / (1000 * 60 * 60)),
minutes: Math.floor((durationMs % (1000 * 60 * 60)) / (1000 * 60)),
// No easy way to get arrival in departure's timezone
};
}
// With Temporal API
function calculateFlightDuration(departure, arrival) {
const departureTime = Temporal.ZonedDateTime.from(departure);
const arrivalTime = Temporal.ZonedDateTime.from(arrival);
// Accurate duration calculation across time zones
const duration = departureTime.until(arrivalTime);
return {
hours: duration.hours,
minutes: duration.minutes,
inLocalTime: arrivalTime.toLocaleString(),
inDepartureTime: arrivalTime.withTimeZone(departureTime.timeZoneId).toLocaleString()
};
}
Here's another example showing how Temporal API handles recurring events, which are notoriously difficult with the current Date object:
// Current approach with Date (complex and error-prone)
function getNextMeetingDates(startDate, count) {
const dates = [];
const current = new Date(startDate);
for (let i = 0; i < count; i++) {
dates.push(new Date(current));
// Add 2 weeks - error-prone due to month boundaries, DST changes, etc.
current.setDate(current.getDate() + 14);
}
return dates;
}
// With Temporal API
function getNextMeetingDates(startDate, count) {
const start = Temporal.PlainDate.from(startDate);
const dates = [];
for (let i = 0; i < count; i++) {
const nextDate = start.add({ days: i * 14 });
dates.push(nextDate);
}
return dates;
}
The Temporal API provides several key advantages:
- Immutability: All Temporal objects are immutable, preventing accidental modifications
- Separate types for different use cases: PlainDate, PlainTime, ZonedDateTime, etc.
- Intuitive methods: Clear, chainable methods for date arithmetic
- Proper timezone handling: Built-in support for timezones and daylight saving time
- Consistent behavior: Works the same way across all browsers
How to Use It Today
The Temporal API is at Stage 3 in the TC39 process as of May 2025, which means it's nearing completion and implementations are being developed. Here's how to use it today:
With the Official Polyfill:
npm install @js-temporal/polyfill
// Import in your code
import { Temporal } from '@js-temporal/polyfill';
// Get the current date and time
const now = Temporal.Now.plainDateTimeISO();
console.log(`Current time: ${now.toString()}`);
With Vite:
# Install the Temporal API polyfill
npm install @js-temporal/polyfill
// main.js or any entry file
import { Temporal } from '@js-temporal/polyfill';
// Now you can use Temporal API in your code
const now = Temporal.Now.plainDateTimeISO();
console.log(`Current time: ${now.toString()}`);
With Next.js:
# Install the Temporal API polyfill
npm install @js-temporal/polyfill
// pages/_app.js
import { Temporal } from '@js-temporal/polyfill';
// Make Temporal available globally if needed
if (typeof window !== 'undefined') {
window.Temporal = Temporal;
}
function MyApp({ Component, pageProps }) {
return <Component {...pageProps} />;
}
export default MyApp;
With tsup:
# Install the Temporal API polyfill
npm install @js-temporal/polyfill
// src/index.ts
import { Temporal } from '@js-temporal/polyfill';
// Export it if you want to make it available to consumers of your package
export { Temporal };
// Your other code here
Browser Support:
- Chrome/Edge: Available behind the "Experimental JavaScript" flag
- Firefox: Available in Firefox 139 and later
- Safari: Not yet implemented
To check if Temporal is natively supported:
if (typeof Temporal !== 'undefined') {
console.log('Temporal API is natively supported');
} else {
console.log('Using polyfill');
// Import polyfill
}
Resource Management Eliminates Memory Leaks
JavaScript has long lacked deterministic resource cleanup. When working with files, database connections, or hardware access, developers must manually ensure resources are properly released - even in error cases.
This limitation leads to memory leaks and resource exhaustion bugs. The typical pattern involves try/finally blocks that quickly become unwieldy:
async function processFile(path) {
let file = null;
try {
file = await fs.promises.open(path, 'r');
const content = await file.readFile({ encoding: 'utf8' });
return processContent(content);
} finally {
if (file) {
await file.close();
}
}
}
The new using
and await using
statements provide deterministic resource management in JavaScript:
async function processFile(path) {
await using file = await fs.promises.open(path, 'r');
const content = await file.readFile({ encoding: 'utf8' });
return processContent(content);
// File is automatically closed when the block exits
}
Let's look at a more complex example with multiple resources that must be managed in a specific order:
// Current approach: Nested try/finally blocks
async function processData(dbConfig, filePath) {
let db = null;
let file = null;
try {
db = await Database.connect(dbConfig);
try {
file = await fs.promises.open(filePath, 'r');
const data = await file.readFile({ encoding: 'utf8' });
const processed = processRawData(data);
return db.store(processed);
} finally {
if (file) {
await file.close();
}
}
} finally {
if (db) {
await db.disconnect();
}
}
}
// With resource management: Clean and safe
async function processData(dbConfig, filePath) {
await using db = await Database.connect(dbConfig);
await using file = await fs.promises.open(filePath, 'r');
const data = await file.readFile({ encoding: 'utf8' });
const processed = processRawData(data);
return db.store(processed);
// Resources are automatically cleaned up in reverse order:
// 1. file is closed
// 2. db is disconnected
}
For database-heavy applications, this feature transforms connection pool management:
class DatabaseConnection {
constructor(config) {
this.config = config;
this.connection = null;
}
async connect() {
this.connection = await createConnection(this.config);
return this;
}
async query(sql, params) {
return this.connection.query(sql, params);
}
async [Symbol.asyncDispose]() {
if (this.connection) {
await this.connection.close();
this.connection = null;
}
}
}
// Using the connection with automatic cleanup
async function getUserData(userId) {
await using db = await new DatabaseConnection(config).connect();
return db.query('SELECT * FROM users WHERE id = ?', [userId]);
// Connection is automatically closed when the function exits
}
Here's another example showing how to manage hardware resources like WebUSB devices:
// Current approach: Manual cleanup required
async function readFromUSBDevice(deviceFilter) {
let device = null;
try {
const devices = await navigator.usb.getDevices();
device = devices.find(d => d.productId === deviceFilter.productId);
if (!device) {
device = await navigator.usb.requestDevice({ filters: [deviceFilter] });
}
await device.open();
await device.selectConfiguration(1);
await device.claimInterface(0);
const result = await device.transferIn(1, 64);
return new TextDecoder().decode(result.data);
} finally {
if (device) {
try {
await device.close();
} catch (e) {
console.error("Error closing device:", e);
}
}
}
}
// With resource management: Automatic cleanup
class USBDeviceResource {
constructor(device) {
this.device = device;
}
static async create(deviceFilter) {
const devices = await navigator.usb.getDevices();
let device = devices.find(d => d.productId === deviceFilter.productId);
if (!device) {
device = await navigator.usb.requestDevice({ filters: [deviceFilter] });
}
await device.open();
await device.selectConfiguration(1);
await device.claimInterface(0);
return new USBDeviceResource(device);
}
async read() {
const result = await this.device.transferIn(1, 64);
return new TextDecoder().decode(result.data);
}
async [Symbol.asyncDispose]() {
try {
await this.device.close();
} catch (e) {
console.error("Error closing device:", e);
}
}
}
async function readFromUSBDevice(deviceFilter) {
await using device = await USBDeviceResource.create(deviceFilter);
return device.read();
// Device is automatically closed when the function exits
}
How to Use It Today
The Explicit Resource Management proposal (using/await using) is at Stage 3 in the TC39 process as of May 2025, which means it's nearing standardization. Here's how to use it today:
With Babel (Generic Setup):
# Install the resource management plugin
npm install --save-dev @babel/plugin-proposal-explicit-resource-management
Add to your .babelrc
:
{
"plugins": ["@babel/plugin-proposal-explicit-resource-management"]
}
With Vite:
# Install dependencies
npm install --save-dev vite-plugin-babel @babel/core @babel/plugin-proposal-explicit-resource-management
// vite.config.js
import { defineConfig } from 'vite';
import babel from 'vite-plugin-babel';
export default defineConfig({
plugins: [
babel({
babelConfig: {
plugins: [
'@babel/plugin-proposal-explicit-resource-management'
]
}
})
]
});
With Next.js:
# Install the resource management plugin
npm install --save-dev @babel/plugin-proposal-explicit-resource-management
// .babelrc
{
"presets": ["next/babel"],
"plugins": [
"@babel/plugin-proposal-explicit-resource-management"
]
}
With tsup:
# Install dependencies
npm install --save-dev tsup @babel/core @babel/plugin-proposal-explicit-resource-management
// tsup.config.ts
import { defineConfig } from 'tsup';
import * as babel from '@babel/core';
import fs from 'fs';
export default defineConfig({
entry: ['src/index.ts'],
format: ['cjs', 'esm'],
dts: true,
esbuildPlugins: [
{
name: 'babel',
setup(build) {
build.onLoad({ filter: /\.(jsx?|tsx?)$/ }, async (args) => {
const source = await fs.promises.readFile(args.path, 'utf8');
const result = await babel.transformAsync(source, {
filename: args.path,
presets: [
['@babel/preset-env', { targets: 'defaults' }],
'@babel/preset-typescript'
],
plugins: [
'@babel/plugin-proposal-explicit-resource-management'
]
});
return {
contents: result?.code || '',
loader: args.path.endsWith('x') ? 'jsx' : 'js'
};
});
}
}
]
});
Making Objects Disposable:
To make your objects work with using
, implement the Symbol.dispose
method:
class MyResource {
[Symbol.dispose]() {
// Cleanup code here
}
}
For async resources, implement Symbol.asyncDispose
:
class MyAsyncResource {
async [Symbol.asyncDispose]() {
// Async cleanup code here
}
}
Polyfill for Symbols:
// polyfill.js
if (!Symbol.dispose) {
Symbol.dispose = Symbol("Symbol.dispose");
}
if (!Symbol.asyncDispose) {
Symbol.asyncDispose = Symbol("Symbol.asyncDispose");
}
Decorators Add Functionality Without Changing Core Logic
JavaScript decorators provide a clean way to modify classes and methods with additional functionality. If you've used TypeScript or frameworks like Angular, you're already familiar with the concept. Now decorators are coming to vanilla JavaScript.
Decorators solve a fundamental problem: how to add cross-cutting concerns like logging, validation, or performance monitoring without cluttering your business logic.
Here's a practical example:
class UserController {
@authenticate
@rateLimit(100)
@validate(userSchema)
@logAccess
async updateUserProfile(userId, profileData) {
// The method is automatically wrapped with:
// 1. Authentication check
// 2. Rate limiting (100 requests per hour)
// 3. Input validation against userSchema
// 4. Access logging
const user = await this.userRepository.findById(userId);
Object.assign(user, profileData);
return this.userRepository.save(user);
}
}
Without decorators, you'd need to manually wrap each method, resulting in code that's harder to read and maintain:
class UserController {
async updateUserProfile(userId, profileData) {
// Authentication check
if (!isAuthenticated()) {
throw new Error('Unauthorized');
}
// Rate limiting
if (isRateLimited(this.constructor.name, 'updateUserProfile', 100)) {
throw new Error('Rate limit exceeded');
}
// Input validation
const validationResult = validateWithSchema(userSchema, profileData);
if (!validationResult.valid) {
throw new Error(`Invalid data: ${validationResult.errors.join(', ')}`);
}
// Logging
logAccess(this.constructor.name, 'updateUserProfile', userId);
// Actual business logic
const user = await this.userRepository.findById(userId);
Object.assign(user, profileData);
return this.userRepository.save(user);
}
}
Here's a real-world example of using decorators for performance monitoring:
class DataProcessor {
@measure
@cache
processLargeDataset(data) {
// Complex and time-consuming operation
return data.map(item => /* complex transformation */)
.filter(item => /* complex filtering */)
.reduce((acc, item) => /* complex aggregation */);
}
}
// Performance measurement decorator
function measure(target, name, descriptor) {
const original = descriptor.value;
descriptor.value = function(...args) {
const start = performance.now();
const result = original.apply(this, args);
const end = performance.now();
console.log(`${name} took ${end - start}ms to execute`);
return result;
};
return descriptor;
}
// Caching decorator
function cache(target, name, descriptor) {
const original = descriptor.value;
const cacheStore = new Map();
descriptor.value = function(...args) {
const key = JSON.stringify(args);
if (cacheStore.has(key)) {
console.log(`Cache hit for ${name}`);
return cacheStore.get(key);
}
console.log(`Cache miss for ${name}`);
const result = original.apply(this, args);
cacheStore.set(key, result);
return result;
};
return descriptor;
}
Decorators are particularly valuable for API development. Here's an example of using decorators to implement a RESTful API with proper error handling:
class ProductAPI {
@route('GET', '/products')
@paginate
@handleErrors
async getAllProducts(req) {
return this.productRepository.findAll();
}
@route('GET', '/products/:id')
@handleErrors
async getProductById(req) {
const product = await this.productRepository.findById(req.params.id);
if (!product) {
throw new NotFoundError(`Product with ID ${req.params.id} not found`);
}
return product;
}
@route('POST', '/products')
@validate(productSchema)
@handleErrors
async createProduct(req) {
return this.productRepository.create(req.body);
}
}
// Route decorator
function route(method, path) {
return function(target, name, descriptor) {
if (!target.constructor._routes) {
target.constructor._routes = [];
}
target.constructor._routes.push({
method,
path,
handler: descriptor.value,
name
});
return descriptor;
};
}
// Error handling decorator
function handleErrors(target, name, descriptor) {
const original = descriptor.value;
descriptor.value = async function(req, res) {
try {
const result = await original.call(this, req);
return res.json(result);
} catch (error) {
if (error instanceof NotFoundError) {
return res.status(404).json({ error: error.message });
}
if (error instanceof ValidationError) {
return res.status(400).json({ error: error.message });
}
console.error(`Error in ${name}:`, error);
return res.status(500).json({ error: 'Internal server error' });
}
};
return descriptor;
}
How to Use Decorators Today
Decorators are at Stage 3 in the TC39 process as of May 2025, with implementations in progress in major browsers. Here's how to use them today:
With Babel (Generic Setup):
# Install the decorators plugin
npm install --save-dev @babel/plugin-proposal-decorators
Add to your .babelrc
:
{
"plugins": [
["@babel/plugin-proposal-decorators", { "version": "2023-05" }]
]
}
With Vite:
# Install dependencies
npm install --save-dev vite-plugin-babel @babel/core @babel/plugin-proposal-decorators
// vite.config.js
import { defineConfig } from 'vite';
import babel from 'vite-plugin-babel';
export default defineConfig({
plugins: [
babel({
babelConfig: {
plugins: [
['@babel/plugin-proposal-decorators', { version: '2023-05' }]
]
}
})
]
});
With Next.js:
# Install the decorators plugin
npm install --save-dev @babel/plugin-proposal-decorators
// .babelrc
{
"presets": ["next/babel"],
"plugins": [
["@babel/plugin-proposal-decorators", { "version": "2023-05" }]
]
}
With TypeScript:
Enable decorators in tsconfig.json
:
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true
}
}
With tsup:
# Install dependencies
npm install --save-dev tsup @babel/core @babel/plugin-proposal-decorators
// tsup.config.ts
import { defineConfig } from 'tsup';
import * as babel from '@babel/core';
import fs from 'fs';
export default defineConfig({
entry: ['src/index.ts'],
format: ['cjs', 'esm'],
dts: true,
esbuildPlugins: [
{
name: 'babel',
setup(build) {
build.onLoad({ filter: /\.(jsx?|tsx?)$/ }, async (args) => {
const source = await fs.promises.readFile(args.path, 'utf8');
const result = await babel.transformAsync(source, {
filename: args.path,
presets: [
['@babel/preset-env', { targets: 'defaults' }],
'@babel/preset-typescript'
],
plugins: [
['@babel/plugin-proposal-decorators', { version: '2023-05' }]
]
});
return {
contents: result?.code || '',
loader: args.path.endsWith('x') ? 'jsx' : 'js'
};
});
}
}
]
});
Important Note:
The decorator proposal has gone through several iterations. Make sure you're using the latest syntax (2023-05 version) as older versions are incompatible. The current specification defines three capabilities for decorators:
- They can replace the decorated value with a matching value
- They can provide access to the decorated value
- They can initialize the decorated value
Testing Your Configuration
After setting up your configuration, create a simple test file to verify that the features are working:
// test-features.js
// Test pipeline operator
const double = x => x * 2;
const add = x => x + 1;
const square = x => x * x;
const result = 5
|> double
|> add
|> square;
console.log("Pipeline result:", result); // Should be 121
// Test pattern matching
function processValue(value) {
return match (value) {
when String(s) => `String: ${s}`,
when Number(n) => `Number: ${n}`,
when { type, data } => `Object with type ${type}`,
when _ => 'Unknown'
};
}
console.log("Pattern matching:", processValue("test"), processValue(42), processValue({ type: "user", data: {} }));
// Test Temporal API
import { Temporal } from '@js-temporal/polyfill';
const now = Temporal.Now.plainDateTimeISO();
console.log("Current time:", now.toString());
// Test resource management
class Resource {
constructor(name) {
this.name = name;
console.log(`Resource ${name} created`);
}
[Symbol.dispose]() {
console.log(`Resource ${this.name} disposed`);
}
}
{
using resource = new Resource("test");
console.log("Using resource");
} // Resource should be disposed here
// Test decorators
function log(target, name, descriptor) {
const original = descriptor.value;
descriptor.value = function(...args) {
console.log(`Calling ${name} with ${JSON.stringify(args)}`);
return original.apply(this, args);
};
return descriptor;
}
class Calculator {
@log
add(a, b) {
return a + b;
}
}
const calc = new Calculator();
console.log("Decorator result:", calc.add(2, 3));
Conclusion
These upcoming JavaScript features aren't just syntactic sugar - they fundamentally change how we write code. At Lingo.dev, we've already seen how they can transform complex, error-prone code into clean, maintainable solutions.
The best part is you don't have to wait. With the right tools, you can start using these features today. Each one solves real problems that developers face daily, from managing resources to handling dates correctly.
What upcoming JavaScript feature are you most excited about? Have you found other ways to solve these problems in your codebase?
Share your thoughts!
Useful links:
- Lingo.dev on Twitter/X - dev + fun;
- GitHub repo - give us a star :)
Top comments (26)
I don't think i agree with the pipeline operator being as useful however, or maybe i just have an issue with the example as it seems very apples to oranges. It's displayed that it's an alternative to a set of nested functions but tbh if i was creating functions to be chained ( like how Arrays have ). I would just do this:
and i would then argue that just having
User(ourUser).enrichUserProfile().normalizeData().validateUser(). saveToDatabase()
Is nicer than a
|>
between each method, and needing to polyfill for earlier versions.In case of a class build composition, you can't use other methods in the build chain, but with pipeline - you can.
Another great article, Max! Thanks for sharing!
Love the new pipeline operator
(|>)
- the code looks so clean!we're moving parts of the codebase to use it now. it's great!
Cool 😎 👌
Been writing elixir for 6 months and these are all things I was thinking “wow, this would be cool if I could do in JS”. So cool to see the proposals coming down the pipe (pun not intended but appreciated)
I live that Temporal is finally becoming a thing. Though it doesn't feel right to call the article "upcoming" when it's talking about some proposals that are in stage 2 and 1 and were in that stage last time I checked ages ago. I'd really like to see some progress to be excited about, like Temporal, them an article would be exciting.
This was packed with so many practical examples, love it! For me, the pipeline operator is a total game changer for making workflows readable, but pattern matching is right up there too. Have you run into any gotchas using the Babel plugin in bigger projects?
Really nice article! I learnt some new tricks.
to me I think the gold star is Temporal API
I'm not sold on the pipeline operator, I'm on the fence about it, I kinda like the indentation of nested functions to visualise whats going on; and I think often it might be a code smell hint to refactor to async await promises if your code becomes nested like that. 🤷♂️
I'm pretty sure the pipeline operator proposal has moved to a slightly different syntax with a placeholder symbol (like Hack) instead of these needing unary functions (like F#), so that it could play better with async/await among other things
Pretty cool seeing JavaScript finally fix a lot of old headaches - kinda pumped to mess with some of this now tbh.
When all languages try to transition to js syntax, js wants to adopt PHP syntax??
do you mean pipeline operator? it is not from PHP, F# was the first use it
I really like the pipeline operator!
Regarding Date, I though Moment.js was deprecated and not maintained anymore, am I wrong since you're talking about it? Luxon is my way to go now.
Luxon is nice! Moment.js is still in lots of codebases though.