Why you shouldn't mix levels of abstraction in your functions

For some time now, I was convinced I could be writing more readable code. But because of the high-paced tempo that comes with working in an agency, I had an excuse not to invest more time in readability. Or so I thought.

You can come up with all sorts of excuses to assure yourself that your code is good enough and that someone else will understand it perfectly in a couple of months. That won’t help you out when you actually get stuck in a trap you set up yourself.

Okay, this sounds really dramatic, but I hope you get the point!

By writing clean code, you’ll be helping someone continue writing code after you, but you’ll help yourself as well. The main problem for me is that I can recognize good code, but when I start coding, all of that falls apart because I’m not sure what really makes code good.

One of the concepts I’ve picked up is the level of abstraction (in your functions). Take a look at this piece of code:

function createUser(username, password) {
    if (username.trim().length <= 2) {
        console.error('Username too short.');
        return;
    }

    if (password.length <= 6 || password.length > 30) {
        console.error('Password must be longer than 6 characters and shorter than 30');
        return;
    }

    const user = {
        username,
        password
    };
    database.insert(user);
}

It’s nothing too complex, right? You can pretty easily determine what this function is doing and what validations it’s checking. But we can make it even easier to read for other developers.

This is an example of mixing levels of abstraction which we should remedy, if possible. The thing why this might seem a bit off is because we’re having some low-level checks (length of username and password) and some high-level functions which handle inserting some data in a database. To make this function more readable, we should try to even out the levels of abstraction.

function createUser(username, password) {
    try {
        validateUserData(username, password)
    } catch(error) {
        showErrorMessage(error);
        return;
    }

    const user = {
        username,
        password
    };
    database.insert(user);
}

function validateUserData(username, password) {
    if (!isUsernameValid(username)) {
        throw new Error('Username too short');
    }

    if (!isPasswordValid(password)) {
        throw new Error('Password must be longer than 6 characters and shorter than 30');
    }
}

function isUsernameValid(username) {
    return username.trim().length > 2;
}

function isPasswordValid(password) {
    return password.length > 6 && password.length <= 30;
}

function showErrorMessage(message) {
    console.error(message);
}

Even though this is a bit more code, I think it’s pretty obvious what the function is doing just by reading the function names. You don’t have to dig deeper into the code and increase your cognitive load to understand what the function should be doing.

Splitting the function this way gave us the opportunity to write even better tests because we can easily focus on edge cases for any function. So you can imagine what this method could do on a more complex function!

I would argue that this would definitely be worth the time you spent splitting this function up. You will be quicker to find bugs and won’t have to spend an absurd amount of time re-familiarizing yourself with the code you wrote half a year ago!