How to Structure your JavaScript for Readability

When we learn to write code, we spend a lot of our time doing exactly that - writing code. As we transition into careers and larger projects however, we find out that we actually spend most of our time reading code - and we quickly learn that developers can't write for shit.

We waste countless hours trying to make sense of muddied, unclear code; banging our head against the wall; and loudly cursing the person who wrote it - all to find out it was actually us.

So how do you claim those hours back, and become a programmer who people like working with? Write readable code!

Following on from my previous post, Introduction to Readable Javascript, I'm going to expand on how to organise and refactor code - this time we're focussing on how to break up larger pieces of code to make them more readable.

The Code

We're going to be working with some code provided by reddit user /u/21wqaszx - a hangman game. Here is the functions.js file.

'use strict'

let game;
const puzzle = document.querySelector('#word');
const msgs = document.querySelector('#messages');
const input = document.querySelector('#input');

// New game constructor
const Hangman = function (word, remainingGuesses = 10) {
    this.guesses = [];
    this.word = word.toUpperCase().split('');
    this.remainingGuesses = remainingGuesses;
    // Ignore spaces
    this.revealed = this.word.map(char => char === ' ' ? ' ' : '*');
    this.updateGameStatus();
}

// Update game values
Hangman.prototype.updateGameStatus = function(){
    document.querySelector('#remaining_guesses').textContent = this.remainingGuesses;
    document.querySelector('#lettersGuessed').textContent = this.guesses.sort();
    puzzle.textContent = `${this.revealed.join('')}`;
}

// Check for correct guesses, invalid inputs, etc
Hangman.prototype.checkGuess = function(g){ 
    // Clear messages
    msgs.textContent = '';

    const re = /[a-zA-Z]/g;
    let guess;
    
    // Check input against regex
    if (!g.match(re)){
        msgs.textContent =`${g} is an incorrect input value, try again!`;
        return;
    }else {
        guess = g.toUpperCase();
    }
    
    // Already guessed?  Notify user & return
    if (this.guesses.findIndex(char => char === guess) > -1){
        msgs.textContent = `${g} was already guessed, try again!`;
        return;
    }
    // Correct guess? Push to guesses[] & remove * from appropriate position(s) in puzzle
    else if (this.word.findIndex(char => char === guess) > -1){
        this.guesses.push(guess);
        for (let i = 0; i < this.word.length; i++){
            if (this.word[i] === guess){
                this.revealed[i] = guess;
            }
        }
        // If no more asterisks, user wins & game is over
        if (this.revealed.findIndex(char => char === '*') < 0){
            msgs.textContent = `Congrats, you win!  The word was ${this.word.join('')}`;
            input.disabled = true;
        }
    }
    // Incorrect guess.  Push to guesses[], subtract 1 from remainingGuesses, end game if remainingGuesses === 0
    else {
        this.guesses.push(guess);
        this.remainingGuesses -= 1;
        if (this.remainingGuesses === 0){
            msgs.textContent = `You lose!  The word was: ${this.word.join('')}`;
            input.disabled = true;
        }
    }
}

// Called by input keyup event listener below
function makeGuess(e){
    game.checkGuess(e.target.value);
    game.updateGameStatus();
    e.target.value = '';
}

// Called by button click event listener below
function newGame(){
    game = new Hangman(getWord());
    msgs.textContent = '';
    input.disabled = false;
}

// New game on button click
document.querySelector('#new').addEventListener('click', newGame)

// Update game values per key press
input.addEventListener('keyup', makeGuess)

// Random word generator
const getWord = function (){
    const words = ['abruptly', 'absurd', 'abyss', 'affix', 'askew', 'avenue', 'awkward', 'axiom', 'azure', 'bagpipes', 'bandwagon'
, 'banjo', 'bayou', 'beekeeper', 'bikini', 'blitz', 'blizzard', 'boggle', 'bookworm', 'boxcar', 'boxful'
, 'buckaroo', 'buffalo', 'buffoon', 'buxom', 'buzzard', 'buzzing', 'buzzwords', 'caliph', 'cobweb', 'cockiness'
, 'croquet', 'crypt', 'curacao', 'cycle', 'daiquiri', 'dirndl', 'disavow', 'dizzying', 'duplex', 'dwarves'
, 'embezzle', 'equip', 'espionage', 'euouae', 'exodus', 'faking', 'fishhook', 'fixable', 'fjord', 'flapjack'
, 'flopping', 'fluffiness', 'flyby', 'foxglove', 'frazzled', 'frizzled', 'fuchsia', 'funny', 'gabby', 'galaxy'
, 'galvanize', 'gazebo', 'giaour', 'gizmo', 'glowworm', 'glyph', 'gnarly', 'gnostic', 'gossip', 'grogginess'
, 'haiku', 'haphazard', 'hyphen', 'iatrogenic', 'icebox', 'injury', 'ivory', 'ivy', 'jackpot', 'jaundice'
, 'jawbreaker', 'jaywalk', 'jazziest', 'jazzy', 'jelly', 'jigsaw', 'jinx', 'jiujitsu', 'jockey', 'jogging'
, 'joking', 'jovial', 'joyful', 'juicy', 'jukebox', 'jumbo', 'kayak', 'kazoo', 'keyhole', 'khaki'
, 'kilobyte', 'kiosk', 'kitsch', 'kiwifruit', 'klutz', 'knapsack', 'larynx', 'lengths', 'lucky', 'luxury'
, 'lymph', 'marquis', 'matrix', 'megahertz', 'microwave', 'mnemonic', 'mystify', 'naphtha', 'nightclub', 'nowadays'
, 'numbskull', 'nymph', 'onyx', 'ovary', 'oxidize', 'oxygen', 'pajama', 'peekaboo', 'phlegm', 'pixel'
, 'pizazz', 'pneumonia', 'polka', 'pshaw', 'psyche', 'puppy', 'puzzling', 'quartz', 'queue', 'quips'
, 'quixotic', 'quiz', 'quizzes', 'quorum', 'razzmatazz', 'rhubarb', 'rhythm', 'rickshaw', 'schnapps', 'scratch'
, 'shiv', 'snazzy', 'sphinx', 'spritz', 'squawk', 'staff', 'strength', 'strengths', 'stretch', 'stronghold'
, 'stymied', 'subway', 'swivel', 'syndrome', 'thriftless', 'thumbscrew', 'topaz', 'transcript', 'transgress', 'transplant'
, 'triphthong', 'twelfth', 'twelfths', 'unknown', 'unworthy', 'unzip', 'uptown', 'vaporize', 'vixen', 'vodka'
, 'voodoo', 'vortex', 'voyeurism', 'walkway', 'waltz', 'wave', 'wavy', 'waxy', 'wellspring', 'wheezy'
, 'whiskey', 'whizzing', 'whomever', 'wimpy', 'witchcraft', 'wizard', 'woozy', 'wristwatch', 'wyvern', 'xylophone'
, 'yachtsman', 'yippee', 'yoked', 'youthful', 'yummy', 'zephyr', 'zigzag', 'zigzagging', 'zilch', 'zipper'
, 'zodiac', 'zombie'];
    const rand = Math.floor(Math.random() * (words.length + 1));
    return words[rand];
}

// Initial start
game = new Hangman(getWord());

The situation

In general, this code is fairly readable. For the most part, it has broken down functions into focussed pieces, and maintains some logical groupings inside the functions. There are also plenty of helpful comments throughout the code.

There are some weaknesses:

  1. there is some inconsistency in how the code is styled and named, eg. #remaining_guesses vs #lettersGuessed.
  2. The checkGuess function is a bit long - it does 4 separate (though related) things.

The program also seems to loosely be following a Model-View-Controller (MVC) structure, so we should keep that in mind as we refactor.

Breaking it into chunks

What we do when we start restructuring larger pieces of code is very similar to what we do when we work with smaller pieces of code: break them into chunks of related code, and separate them out.

My first pass at this is going to be to look at what is happening at the top-level of functions.js (basically anything that isn't inside a function), and seeing what kind of logical groups I can make. Here's a breakdown of what's happening:

  1. A couple of global const
  2. The Hangman class, and functions.
  3. Some event listeners for handling input
  4. The setup code: newGame, the getWord function, and the actual initialisation of Hangman.

Separate out classes

As a general rule, you want each class in its own file, so the first thing I want to do here is extract out the Hangman class. This is doubly important in this situation, because Hangman is the only thing responsible for our game state, so it would be good to separate that out from input handling, and setup code.

As I do this, I'm going to make a couple of stylistic changes to help make things easier to read:

  1. I'm going to use the ES6 class syntax for Hangman. It's less verbose, so we don't have to spend as much time sifting through the Hangman.prototype nonsense for every single function
  2. I'm going to buff up the comments at the start of functions to use /** */ comments instead of // – It's a good idea to use visually weighty comments at the start of your functions and your classes. It makes it a lot easier to see the chunks you've divided your code into.

Here is the new functions.js file. There are still a couple of unresolved references to the other file, but we'll address those later on.

/**
 * Main class for the hangman game
 */
class Hangman {

    constructor(word, remainingGuesses = 10) {
        this.guesses = [];
        this.word = word.toUpperCase().split('');
        this.remainingGuesses = remainingGuesses;
        this.revealed = this.word.map(char => char === ' ' ? ' ' : '*'); //Ignore spaces
        this.updateGameStatus();
    }

    /**
     * Update game values
     */
    updateGameStatus() {
        document.querySelector('#remaining_guesses').textContent = this.remainingGuesses;
        document.querySelector('#lettersGuessed').textContent = this.guesses.sort();
        puzzle.textContent = `${this.revealed.join('')}`;
    }

    /**
     * Check for correct guesses, invalid inputs, etc
     */
    checkGuess(g) {
        // Clear messages
        msgs.textContent = '';

        const re = /[a-zA-Z]/g;
        let guess;

        // Check input against regex
        if (!g.match(re)){
            msgs.textContent =`${g} is an incorrect input value, try again!`;
            return;
        } else {
            guess = g.toUpperCase();
        }

        // Already guessed?  Notify user & return
        if (this.guesses.findIndex(char => char === guess) > -1){
            msgs.textContent = `${g} was already guessed, try again!`;
            return;
        }
        // Correct guess? Push to guesses[] & remove * from appropriate position(s) in puzzle
        else if (this.word.findIndex(char => char === guess) > -1){
            this.guesses.push(guess);
            for (let i = 0; i < this.word.length; i++){
                if (this.word[i] === guess){
                    this.revealed[i] = guess;
                }
            }
            // If no more asterisks, user wins & game is over
            if (this.revealed.findIndex(char => char === '*') < 0){
                msgs.textContent = `Congrats, you win!  The word was ${this.word.join('')}`;
                input.disabled = true;
            }
        }
        // Incorrect guess.  Push to guesses[], subtract 1 from remainingGuesses, end game if remainingGuesses === 0
        else {
            this.guesses.push(guess);
            this.remainingGuesses -= 1;
            if (this.remainingGuesses === 0){
                msgs.textContent = `You lose!  The word was: ${this.word.join('')}`;
                input.disabled = true;
            }
        }
    }
}

export default Hangman;

In our functions.js file, here is what we're left with:

import Hangman from './hangman.js';

let game;
const puzzle = document.querySelector('#word');
const msgs = document.querySelector('#messages');
const input = document.querySelector('#input');

// Called by input keyup event listener below
function makeGuess(e){
    game.checkGuess(e.target.value);
    game.updateGameStatus();
    e.target.value = '';
}

// Called by button click event listener below
function newGame(){
    game = new Hangman(getWord());
    msgs.textContent = '';
    input.disabled = false;
}

// New game on button click
document.querySelector('#new').addEventListener('click', newGame)

// Update game values per key press
input.addEventListener('keyup', makeGuess)

// Random word generator
const getWord = function (){
    const words = ['abruptly', 'absurd', 'abyss', 'affix', 'askew', 'avenue', 'awkward', 'axiom', 'azure', 'bagpipes', 'bandwagon'
, 'banjo', 'bayou', 'beekeeper', 'bikini', 'blitz', 'blizzard', 'boggle', 'bookworm', 'boxcar', 'boxful'
, 'buckaroo', 'buffalo', 'buffoon', 'buxom', 'buzzard', 'buzzing', 'buzzwords', 'caliph', 'cobweb', 'cockiness'
, 'croquet', 'crypt', 'curacao', 'cycle', 'daiquiri', 'dirndl', 'disavow', 'dizzying', 'duplex', 'dwarves'
, 'embezzle', 'equip', 'espionage', 'euouae', 'exodus', 'faking', 'fishhook', 'fixable', 'fjord', 'flapjack'
, 'flopping', 'fluffiness', 'flyby', 'foxglove', 'frazzled', 'frizzled', 'fuchsia', 'funny', 'gabby', 'galaxy'
, 'galvanize', 'gazebo', 'giaour', 'gizmo', 'glowworm', 'glyph', 'gnarly', 'gnostic', 'gossip', 'grogginess'
, 'haiku', 'haphazard', 'hyphen', 'iatrogenic', 'icebox', 'injury', 'ivory', 'ivy', 'jackpot', 'jaundice'
, 'jawbreaker', 'jaywalk', 'jazziest', 'jazzy', 'jelly', 'jigsaw', 'jinx', 'jiujitsu', 'jockey', 'jogging'
, 'joking', 'jovial', 'joyful', 'juicy', 'jukebox', 'jumbo', 'kayak', 'kazoo', 'keyhole', 'khaki'
, 'kilobyte', 'kiosk', 'kitsch', 'kiwifruit', 'klutz', 'knapsack', 'larynx', 'lengths', 'lucky', 'luxury'
, 'lymph', 'marquis', 'matrix', 'megahertz', 'microwave', 'mnemonic', 'mystify', 'naphtha', 'nightclub', 'nowadays'
, 'numbskull', 'nymph', 'onyx', 'ovary', 'oxidize', 'oxygen', 'pajama', 'peekaboo', 'phlegm', 'pixel'
, 'pizazz', 'pneumonia', 'polka', 'pshaw', 'psyche', 'puppy', 'puzzling', 'quartz', 'queue', 'quips'
, 'quixotic', 'quiz', 'quizzes', 'quorum', 'razzmatazz', 'rhubarb', 'rhythm', 'rickshaw', 'schnapps', 'scratch'
, 'shiv', 'snazzy', 'sphinx', 'spritz', 'squawk', 'staff', 'strength', 'strengths', 'stretch', 'stronghold'
, 'stymied', 'subway', 'swivel', 'syndrome', 'thriftless', 'thumbscrew', 'topaz', 'transcript', 'transgress', 'transplant'
, 'triphthong', 'twelfth', 'twelfths', 'unknown', 'unworthy', 'unzip', 'uptown', 'vaporize', 'vixen', 'vodka'
, 'voodoo', 'vortex', 'voyeurism', 'walkway', 'waltz', 'wave', 'wavy', 'waxy', 'wellspring', 'wheezy'
, 'whiskey', 'whizzing', 'whomever', 'wimpy', 'witchcraft', 'wizard', 'woozy', 'wristwatch', 'wyvern', 'xylophone'
, 'yachtsman', 'yippee', 'yoked', 'youthful', 'yummy', 'zephyr', 'zigzag', 'zigzagging', 'zilch', 'zipper'
, 'zodiac', 'zombie'];
    const rand = Math.floor(Math.random() * (words.length + 1));
    return words[rand];
}

// Initial start
game = new Hangman(getWord());

Look for further distractions

Looking at the functions.js file, there is now one element that strongly dominates the file: the getWord function.

Although getWord is technically part of our setup code, it is also logically distinct - It's a random word generator. We depend on it to create our game, but the function itself is not directly concerned with it.

You might have noticed that the issue with the getWord function is mainly from const words, and not the whole function, so you might have been tempted to extract just the list. The reason I opted not to here is because it has a strong relationship to getWord, and that gets obscured if you separate them - a net loss for readability - after all, we like to keep related code together.

Here is our new functions.js.

import Hangman from './hangman.js';
import getWord from '/words.js';

let game;
const puzzle = document.querySelector('#word');
const msgs = document.querySelector('#messages');
const input = document.querySelector('#input');

// Called by input keyup event listener below
function makeGuess(e){
    game.checkGuess(e.target.value);
    game.updateGameStatus();
    e.target.value = '';
}

// Called by button click event listener below
function newGame(){
    game = new Hangman(getWord());
    msgs.textContent = '';
    input.disabled = false;
}

// New game on button click
document.querySelector('#new').addEventListener('click', newGame);

// Update game values per key press
input.addEventListener('keyup', makeGuess);

// Initial start
game = new Hangman(getWord());

I'm happy with how functions.js looks now. I would love to give you some metric for when a file is "done", but it's highly subjective, and any rule I provide is going to be more harmful than useful.

It would be possible to separate the file further, but it wouldn't improve readability significantly - in fact it would probably hinder it. If the chunks we work with become too small, we end up with Code Fragmentation - it becomes difficult to read simply because it's spread all over the place.

Clean up

I said before that we left a few hanging references in our new hangman.js file, and I would get to it later - now is me getting to it later.

All 3 of our constants are declared in the functions.js file, but used in hangman.js. If we try to run our code at the moment, we'll get a NullPointerException. We have a couple of options as to how we can fix this:

  1. We can copy the variable declarations across and have it in both functions.js and hangman.js
  2. We can pass the variables into the constructor, and store a reference in the Hangman class.
  3. We can refactor the Hangman class to not have any knowledge of the UI elements, and provide return values to our functions to update messages.
  4. We can  move the declarations over to Hangman, since puzzle isn't used in function.js, and the other two are only used once – and even then only as part of the newGame() code.

Technically speaking, option 3 is the cleanest - It gives the cleanest separation for our MVC pattern, and makes hangman.js more robust because it no longer relies on a specific DOM setup.

I'm going to implement option 4, however, since I want to keep as much of the original code in-tact as possible. I would encourage you to give option 3 a try yourself - you can also take a run at breaking down the checkGuess function to make it more readable.

Be sure to sign up to my mailing list below to get more articles like this straight to your inbox. I'm currently working on a free goodie for people who sign up to my mailing list - more to come in future.

The final files:

functions.js

import Hangman from './hangman.js';
import getWord from './words.js';

let game;

// Called by input keyup event listener below
function makeGuess(e){
    game.checkGuess(e.target.value);
    game.updateGameStatus();
    e.target.value = '';
}

// Called by button click event listener below
function newGame(){
    game = new Hangman(getWord());
}

// New game on button click
document.querySelector('#new').addEventListener('click', newGame);

// Update game values per key press
input.addEventListener('keyup', makeGuess);

// Initial start
game = new Hangman(getWord());

hangman.js

const puzzle = document.querySelector('#word');
const msgs = document.querySelector('#messages');
const input = document.querySelector('#input');

/**
 * Main class for the hangman game
 */
export default class Hangman {

    constructor(word, remainingGuesses = 10) {
        this.guesses = [];
        this.word = word.toUpperCase().split('');
        this.remainingGuesses = remainingGuesses;
        this.revealed = this.word.map(char => char === ' ' ? ' ' : '*'); //Ignore spaces

        msgs.textContent = '';
        input.disabled = false;

        this.updateGameStatus();
    }

    /**
     * Update game values
     */
    updateGameStatus() {
        document.querySelector('#remaining_guesses').textContent = this.remainingGuesses;
        document.querySelector('#lettersGuessed').textContent = this.guesses.sort();
        puzzle.textContent = `${this.revealed.join('')}`;
    }

    /**
     * Check for correct guesses, invalid inputs, etc
     */
    checkGuess(g) {
        // Clear messages
        msgs.textContent = '';

        const re = /[a-zA-Z]/g;
        let guess;

        // Check input against regex
        if (!g.match(re)){
            msgs.textContent =`${g} is an incorrect input value, try again!`;
            return;
        } else {
            guess = g.toUpperCase();
        }

        // Already guessed?  Notify user & return
        if (this.guesses.findIndex(char => char === guess) > -1){
            msgs.textContent = `${g} was already guessed, try again!`;
            return;
        }
        // Correct guess? Push to guesses[] & remove * from appropriate position(s) in puzzle
        else if (this.word.findIndex(char => char === guess) > -1){
            this.guesses.push(guess);
            for (let i = 0; i < this.word.length; i++){
                if (this.word[i] === guess){
                    this.revealed[i] = guess;
                }
            }
            // If no more asterisks, user wins & game is over
            if (this.revealed.findIndex(char => char === '*') < 0){
                msgs.textContent = `Congrats, you win!  The word was ${this.word.join('')}`;
                input.disabled = true;
            }
        }
        // Incorrect guess.  Push to guesses[], subtract 1 from remainingGuesses, end game if remainingGuesses === 0
        else {
            this.guesses.push(guess);
            this.remainingGuesses -= 1;
            if (this.remainingGuesses === 0){
                msgs.textContent = `You lose!  The word was: ${this.word.join('')}`;
                input.disabled = true;
            }
        }
    }
}

words.js

const words = ['abruptly', 'absurd', 'abyss', 'affix', 'askew', 'avenue', 'awkward', 'axiom', 'azure', 'bagpipes', 'bandwagon'
    , 'banjo', 'bayou', 'beekeeper', 'bikini', 'blitz', 'blizzard', 'boggle', 'bookworm', 'boxcar', 'boxful'
    , 'buckaroo', 'buffalo', 'buffoon', 'buxom', 'buzzard', 'buzzing', 'buzzwords', 'caliph', 'cobweb', 'cockiness'
    , 'croquet', 'crypt', 'curacao', 'cycle', 'daiquiri', 'dirndl', 'disavow', 'dizzying', 'duplex', 'dwarves'
    , 'embezzle', 'equip', 'espionage', 'euouae', 'exodus', 'faking', 'fishhook', 'fixable', 'fjord', 'flapjack'
    , 'flopping', 'fluffiness', 'flyby', 'foxglove', 'frazzled', 'frizzled', 'fuchsia', 'funny', 'gabby', 'galaxy'
    , 'galvanize', 'gazebo', 'giaour', 'gizmo', 'glowworm', 'glyph', 'gnarly', 'gnostic', 'gossip', 'grogginess'
    , 'haiku', 'haphazard', 'hyphen', 'iatrogenic', 'icebox', 'injury', 'ivory', 'ivy', 'jackpot', 'jaundice'
    , 'jawbreaker', 'jaywalk', 'jazziest', 'jazzy', 'jelly', 'jigsaw', 'jinx', 'jiujitsu', 'jockey', 'jogging'
    , 'joking', 'jovial', 'joyful', 'juicy', 'jukebox', 'jumbo', 'kayak', 'kazoo', 'keyhole', 'khaki'
    , 'kilobyte', 'kiosk', 'kitsch', 'kiwifruit', 'klutz', 'knapsack', 'larynx', 'lengths', 'lucky', 'luxury'
    , 'lymph', 'marquis', 'matrix', 'megahertz', 'microwave', 'mnemonic', 'mystify', 'naphtha', 'nightclub', 'nowadays'
    , 'numbskull', 'nymph', 'onyx', 'ovary', 'oxidize', 'oxygen', 'pajama', 'peekaboo', 'phlegm', 'pixel'
    , 'pizazz', 'pneumonia', 'polka', 'pshaw', 'psyche', 'puppy', 'puzzling', 'quartz', 'queue', 'quips'
    , 'quixotic', 'quiz', 'quizzes', 'quorum', 'razzmatazz', 'rhubarb', 'rhythm', 'rickshaw', 'schnapps', 'scratch'
    , 'shiv', 'snazzy', 'sphinx', 'spritz', 'squawk', 'staff', 'strength', 'strengths', 'stretch', 'stronghold'
    , 'stymied', 'subway', 'swivel', 'syndrome', 'thriftless', 'thumbscrew', 'topaz', 'transcript', 'transgress', 'transplant'
    , 'triphthong', 'twelfth', 'twelfths', 'unknown', 'unworthy', 'unzip', 'uptown', 'vaporize', 'vixen', 'vodka'
    , 'voodoo', 'vortex', 'voyeurism', 'walkway', 'waltz', 'wave', 'wavy', 'waxy', 'wellspring', 'wheezy'
    , 'whiskey', 'whizzing', 'whomever', 'wimpy', 'witchcraft', 'wizard', 'woozy', 'wristwatch', 'wyvern', 'xylophone'
    , 'yachtsman', 'yippee', 'yoked', 'youthful', 'yummy', 'zephyr', 'zigzag', 'zigzagging', 'zilch', 'zipper'
    , 'zodiac', 'zombie'];

// Random word generator
export default function getWord() {
    const rand = Math.floor(Math.random() * (words.length + 1));
    return words[rand];
};

Can't get past JavaScript Tutorials?

Download my FREE ebook on how to succeed as a self-taught JavaScript Developer, and how to find projects that you'll actually finish. Learn More...