Go back

Syntactic Sugar: Sweetening up the JavaScript syntax

In this blog entry I'll take a closer look at so-called "syntactic sugar", which are syntactic simplifications designed to make life easier for programmers. We'll also delve into what's actually happening behind the scenes.

1. Arrow functions

The first feature I'll discuss is arrow functions, which have become a staple in modern JavaScript code.

Introduced in ECMAScript 2015 (ES6), arrow functions were inspired by lambda expressions in other programming languages. These are anonymous functions that can be defined inline, often as arguments to other functions. They are characterized by their conciseness, functionality, and seamless integration with other language constructs.

// We define an array of numbers for the first variable, and then - we map and multiply each of them for the other
  
// Old way:
const numbers = [1, 2, 3];
const doubled = numbers.map(function(n) {
    return n * 2;
});

// New sposób:
const numbers = [1, 2, 3];
const doubled = numbers.map(n => n * 2);

As you can see, arrow functions eliminate the need for unnecessary keywords and entire code blocks for simple operations, making code more readable.

While inspired by lambdas, remember that arrow functions are only a simplified implementation.

Funkcje strzałkowe pod maską

Arrow functions don't create their own execution context, unlike regular functions. Their context is determined at definition time, not execution time, meaning it remains unchanged regardless of where the function is called. This is known as "lexical this binding". Traditionally, this in regular functions is dynamic and depends on the execution context.

In environments that don't support ES6, you can use transpilation to convert newer code to older standards like ES5. Arrow functions are transformed into regular functions while preserving the correct context. This is achieved by assigning the value of this to a variable, which is then used within the arrow function. Example:

// ES6 and newer
class Example {
    constructor() {
        this.value = 5;
        this.getValue = () => this.value;
    }
}

// ----------
// After transpiling to ES5
function Example() {
    var _this = this; // new variable contai
thisthis.value = 5; this.getValue = function() { return _this.value; } }

This transpiled code is then included in our JavaScript code, sent to and executed in the user's browser. As a result, even older software that doesn't support the latest standards can fully utilize our website or application.

Lack of own arguments

Arrow functions don't have their own arguments object. In regular functions, arguments is a dynamically declared list of arguments, even if the function doesn't have explicitly defined parameters. In arrow functions, arguments is treated as a regular variable and must be explicitly declared if needed.

function normalFn() {
    console.log(arguments);
}
normalFn(1, 2, 3, 4); // result: [1, 2, 3, 4]

const arrowFn = () => {
    console.log(arguments);
}
arrowFn(1, 2, 3, 4); // result: error, arguments is not defined

Returning objects from arrow functions

When trying to return a new object using an arrow function, we might intuitively write something like this:

const createObj = (values) => {
    ...values,
    param: 5,
};

However, this is not valid syntax. Opening curly braces right after the arrow in JavaScript signifies the start of a new code block, where the compiler expects executable code, not just an object to return. To return a new object from an arrow function, just like we can return any other value, we need to wrap the object in parentheses:

const createObj = (values) => ({
    ...values,
    param: 5,
});

This prepared and returned object is immediately ready to be served. 😜

Other limitations

Arrow functions, as described so far, also have a few other limitations—they cannot be used as object constructors, as is the case with traditional functions. Therefore, they cannot be called with the new keyword, nor can they use the super() function or the new.target property. These limitations also stem from the fact that arrow functions do not create their own execution context.

2. Template Literals

Template literals were also introduced in JavaScript with the ES6 standard, much like arrow functions. They allow for the creation of strings in a way that was previously unavailable.

Multi-line strings

Template literals can be declared intuitively across multiple lines. Previously, we had to use the so-called "escape character", represented in many programming languages by \n, which made the code declaring text much less readable.

// ES6 or newer
const text = `Lorem ipsum dolor sit amet.

Consectetur adipiscing elit`;

// Transpiled to ES5:
var text = "Lorem ipsum dolor sit amet.\n\nConsectetur adipiscing elit";

Value Interpolation

By using the dollar sign and curly braces, we can perform value interpolation within templates. This technique literally involves dynamically substituting variable values or expressions directly into the string defined in the code.

Interpolation elements can contain any valid JavaScript code that returns a value—meaning that if we want, we can substitute variables, mathematical operations, or even declare and execute functions, provided that we return a valid primitive type that will be converted to a string. However, I recommend keeping the expressions as simple as possible and avoiding interpolating complex code for readability.

The code using interpolation is converted into string concatenation during transpilation, which is simply adding strings and expressions together.

// ES6 and newer
const name = "Patryk";
const text = "Hello, ${name}!`;

// ----------

// Transpiled to ES5:
var name = "Patryk";
var text = "Hello, " + name + "!";

// ----------

// result in both cases: "Hello, Patryk!"

Tagged Template Literals

Although the name sounds very mysterious, there's no great philosophy here. By "tagging," we simply mean using functions declared in a specific way that return a specific type of data (in this case, strings) and accept: a) a template literal and b) optionally, an array of interpolated values. These functions allow us to easily process text in the provided template literals using special syntax that does not require parentheses to call these functions.

Each interpolated value is treated as an element of the array in the second argument of the function. This way, we can separately process interpolated data if needed.

This solution is used by popular libraries for styling React components, such as styled-components or emotion.

//  Function that colors the text displayed in the log gree
function green(strings) {
    return `\x1b[32m${strings[0]}\x1b[0m`;
}

// Display logs with two strings—one processed by the `green` function, the other not:
console.log(green`Success!`, "Action completed successfully!");

Result:

Tagging template literals
Tagging template literals

After transpilation to ES5, code containing tagged template literals is converted into two "regular" functions that process the provided string along with the interpolated properties it contains. Additionally, a variable is created to serve as a cache for the processed template literals.

// Cache for our text template// It stores the processed template so that it doesn't have to be recreated each time.var templateCache; // Helper function that processes our text template. function prepareTemplate(textArray, rawArray) {// If the second array (raw) is not provided, copy the first oneif (!rawArray) { rawArray = textArray.slice(0); }// Freeze the object containing our text to prevent changes. From now on, JS will not be able to modify existing fields. // Add a special 'raw' property containing the processed characters as text.return Object.freeze( Object.defineProperties( textArray, { raw: { value: Object.freeze(rawArray), } } ) ); }

The first function takes as arguments an array of strings and an optional array of "raw" strings. This function is an exact implementation of the standard introduced in ES6.

The second function is the transpiled version of our function that colors the text in the console green. It is converted into a regular function that concatenates values:

function green(strings) {
    var greenColorCode = "\x1B[32m";
var resetColorCode = "\x1B[0m";// We take the first element from the strings array and concatenate the color codes for it:var output = greenColorCode + strings[0] + resetColorCode; //We return the processed string:return output; }

Then, the calls to our template strings with tagging functions are replaced with calls using the variable containing the cache:

console.log(
    green(
        temlateCache || (templateCache = prepareTemplate(["Success!"])
    )
);

You can see (and modify) the exact workings of Babel, the most popular choice for transpiling JavaScript code to older versions, on the babeljs.io website.

3. Destructuring

Destructuring can be used on two data structures: objects and arrays. It allows us to extract properties from objects or elements from arrays into variables—in the case of objects, the variables will have the same names as the properties being extracted (though we can map them immediately), while in the case of arrays, we can name them arbitrarily, but we must remember to assign the correct names to the corresponding array indices.

Examples:

// Object destructuring
const obj = { val1: 5, val2: 10 };
const { val1, val2 } = obj; // At this point, two variables named val1 and val2 are created

console.log(val1, val2); // Result: 5, 10

//  Array destructuring
const arr = [1, 2, 3, 'elem'];
const [elem1, elem2, elem3, elem4] = arr; // At this point, four variables named elem1...elem4 are created

console.log(elem1, elem2, elem3, elem4); // Result: 1, 2, 3, 'elem'

Destructuring under the hood

To perform destructuring, JavaScript executes a series of operations to assign values to the appropriate variables:

  • It checks if the object on the right side is an object or an array.

  • It tries to find properties with names matching the keys of the object on the left side or the corresponding array indices.

  • It assigns the values of the found properties or indices to new variables.

Transpilers like Babel convert destructuring code into traditional assignments to ensure compatibility with older versions of JavaScript.

// Transpiled code
const arr = [1, 2, 3, 'elem'];
const elem1 = arr[0];
const elem2 = arr[1];
const elem3 = arr[2];
const elem4 = arr[3];

Destructuring with default values

If we have an object or array where we are unsure whether certain properties or elements exist, we can use default values during destructuring.

// Objects
const obj = { a: 'a', b: 'b' };

const { a = '1', b = '2', c = '3' };
console.log(a, b, c); // result: 'a', 'b', 3

// Arrays
const arr = [1, 2];
const [a = 'a', b = 'b', c = 'c'] = arr;

console.log(a, b, c); // result: 1, 2, 'c'

In both examples above, we declare an object and an array with two elements. Below them, we attempt to destructure three elements, but with default values—this allows us to successfully assign values from the object and array to new variables, while the third element is assigned the default value since it does not exist in the destructured object.

Nested Object and Array Destructuring

Just as we can nest objects and arrays within other objects and arrays, we can destructure them. The syntax in such cases is intuitive and consistent.

// Objects
const obj = {
    a: 'a',
    obj2: {
        b: 'b',
        c: 'c',
    }
};
const { a, obj2: { b, c } } = obj; // at this point, three variables a, b, and c are created

console.log(a, b, c); // result: 'a', 'b', 'c'

// Tablice
const arr = [1, 2, [3, 4]];
const [a, b, [c, d]] = arr; // at this point, four variables a, b, c, and d are created

console.log(a, b, c, d); // result: 1, 2, 3, 4

Nested data destructuring is particularly useful when working with deeply nested objects—for example, API responses where we have extensive information, such as a list of users, containing embedded addresses, their lists of belongings, etc.

Destructuring Function Parameters

When accepting parameters within a function declaration, we can immediately destructure the needed elements and process them into variables that operate within the lexical scope of that function.

function add([a, b, c]) {
    return a + b + c;
}

const nums = [1, 2, 3];
console.log(add(nums)); // result: 6

As we can see, destructuring allows us to make the code very concise and simple, and much more readable by eliminating the need to repeatedly reference the same object or array. Instead, we can easily access its properties through individual variables in one place.

4. async/await

This mechanism is probably the biggest and most revolutionary among those described here, introduced in recent years in JavaScript. Working with asynchronous operations has always been a bit troublesome, especially if these operations are complex, intricate, and interdependent.

Promise

In ES6, the same standard that gave us arrow functions, among other things, introduced so-called Promises. Promises were a new type of object that represent the results of asynchronous operations, which at the time of calling may still be incomplete, but they promise that they will be completed (successfully or with an error) in the future.

They were meant to replace callbacks, the previous method of nesting many functions within each other, passed between them as arguments, which are executed after some operation—asynchronous or synchronous—is completed. A typical example is the use of timeouts. The built-in JS function setTimeout takes as its first argument a function that will be executed after the specified time has elapsed.

setTimeout(
    // function called asynchronously after the countdown—callback
    () => {
        console.log('This log will appear in the console after 3 seconds!');
    },

    // countdown time in milliseconds before the callback is called
    3000
);

Thanks to Promises, we can replace such a callback with a function that returns a Promise, which in its implementation will use setTimeout to execute specific code after the specified time has elapsed:

function wait() {
    return new Promise((resolve) => {
        // // "resolve" as a callback successfully completes the Promise after 3 seconds
        setTimeout(resolve, 3000);
    });
}

wait()
    .then(() => {
        // Promise successfully executed after 3 seconds
        console.log('This log will appear in the console after 3 seconds!');
    })
    .catch((err) => {
        // In case of an error—we can handle it here
        console.error('An error occurred. ', err);
    });

async/await as a syntactic sugar for Promises

The async/await mechanism is entirely based on working with Promises—however, it heavily hides their complexity, allowing us to write asynchronous code as if it were synchronous.

The async keyword before a function means that it is supposed to return a Promise. Even if we return a synchronous value—for example, the number 5—it will automatically be wrapped in a Promise object.

const getFive = async () => {
    return 5;
}

// getFive() returns a Promise object, not 5!
const value = getFive();

console.log(value); // result: Promise { <fulfilled>: 5 }

To extract the "real" value from an asynchronous function, we must use the await keyword. Note—we can only do this inside another asynchronous function, which also returns a Promise.

const getFive = async () => 5;

const run = async () => { const value = await getFive();

console.log('I have the five! ' + value); // result: "Mam piątkę! 5" };

run();Even though the run function is asynchronous and returns a Promise—we do not expect any value from it that we would need to extract. We only want it to be executed, and that is what happens. The above code will correctly execute the function's body and display the appropriate log in the console, with the correctly expected number 5.

Error Handling in async/await

Error handling within async/await operations has also been well thought out. The try..catch block in JS can catch Promises that have failed or in which an error has been thrown.

Let's use the example of extracting the number five from an asynchronous function again, this time throwing an error before returning the value.

lang:js const getFive = async () => { throw new Error('Error!'); return 5; };

const run = async () => { try { const value = await getFive(); console.log('I have teh five! ' + value); } catch (error) { console.error('Async function error!'); } };

run();

This time, in the console, we will not see the message with the number, as in the previous code, but we will see the error information: Error: Async function error! at run (<anonymous>:2:15) at async <anonymous>:13:9

async/await under the hood

Under the hood, JS transforms every use of async/await into regular Promises and generators. Functions declared with the async prefix are transformed into functions that directly return a Promise object, and every use of await is translated into a chain with .then().

5. Other Syntactic Sugar in JS

JavaScript is full of other, smaller, though no less important, syntactic sugar that can prove helpful in everyday coding. Below, I will describe them very briefly.

Nullish Coalescing Operator

The ?? operator, which returns the value on the right side if the one on the left is null or undefined:

const value = null;
const result = value ?? 'Something else';
console.log(result); // rezultat: "Something else"

Optional Chaining Operator

The ?. operator, which allows for safe calling of a specific property of an object or its function, even if it does not exist. If we try to reference something that would normally throw an error saying that we are trying to call something that does not exist, instead of an error, the entire expression will simply return undefined.

address: {
    name: 'Long Street',
};

console.log(user.address?.name); // result: "Long Street"
console.log(user.address?.number); // result: undefined

Class syntax

Since JS is a prototype-based language, there are no classes in the traditional sense—instead, so-called prototypes exist in the language. Classes defined by us with the class keyword are translated under the hood into prototypes—but we can describe them similarly to any other object-oriented language based on classes.

class User {
    constructor(name) {
        this.name = name;
    }

    getName() {
        return this.name;
    }
}

const patryk = new User("Patryk");
console.log(patryk.getName());  // result: "Patryk"

Spread and Rest operators

The spread operator makes it easier to create new objects and arrays that are extensions of existing ones. The new array or object will contain all the existing elements of the array or object that we are "spreading" into another:

const numbers = [1, 2, 3];
const newNumbers = [...numbers, 4, 5, 6]; // result: [1, 2, 3, 4, 5, 6];

The rest operator, on the other hand, allows us to capture the remaining function arguments as a single variable containing an array:

const restFn(...numbers) {
    // log all numbers passed as separate function arguments
    console.log(numbers);
}

restFn(1, 2, 3, 4, 5); // result: [1, 2, 3, 4, 5];

for..of and for..in syntax

The last syntactic sugar described here will be for..of, which allows for easier iteration over array elements, directly over their values.

for..in allows iteration over indexes:

const arr = ['a', 'b', 'c'];

for (const index in arr) {
    // The `index` variable contains the current array index we are iterating over
    console.log(index, arr[index]);
}

// results:
// 0, "a"
// 1, "b"
// 2, "c"

for..of, on the other hand, iterates directly over values:

const arr = ['a', 'b', 'c'];

for (const value of arr) {
    console.log(value);
}

// results:
// "a"
// "b"
// "c"

Summary

In this post, I aimed to describe in as much detail as possible the most important syntactic sugar in JavaScript and provide examples that explain how code works in such simplified syntax. I hope it helps you understand how these conveniences function and how to use them in everyday programming to work efficiently and create readable code.