5 principles for writing good functions

Photo by Luca Bravo on Unsplash

5 principles for writing good functions

I'd like share some principles I think about whenever I'm writing a function. Things that will make your work easier.

ยท

3 min read

They are not hard rules that must never be broken, but simply helpful guidance.

It may sound like a trivial topic. Everybody can write a function, but to me, this is what separates good code from bad code. By bad code, I mean code that it's hard to read, hard to test, hard to change and hard to maintain. Writing a good function can also be very hard to do, so it takes a lot of practice.

Take what you need, leave what you don't

// ๐Ÿ˜ฑ
async function getUserPosts(user) { 
    return service.getPosts(user.id)
}
// ๐Ÿ˜
async function getUserPosts(userId) { 
    return service.getPosts(userId)
}

Don't pass in more than what the function needs. In this example, we pass in the user object, even though we're only using the id. In a test, you will have an easier time just passing in the id, and not having to pass in an entire user to convince Typescript your code is correct. Also, the naming will become more explicit.

Pass in everything the function needs

// ๐Ÿ˜ฑ
async function getUserPosts(userId) { 
    return service.getPosts(userId)
}
// ๐Ÿ™‚
async function getUserPosts(service, userId) { 
    return service.getPosts(userId)
}
// ๐Ÿฅน๐Ÿ˜
function createGetUserPosts(getPosts) {
    return async function(userId: string) {
        return getPosts(userId)
    }
}
const getUserPosts = createGetUserPosts(service.getPosts)

In the first example, where did the service come from? Maybe we're in a class and the service is passed to the constructor way up in the file. Maybe it's one of five dependencies. You're going to enjoy testing a lot more if you write it in one of the latter examples. The third example is using a concept of higher-order functions for achieving dependency injection. This in itself deserves its own article, so be on the lookout for that.

Avoid having too many parameters

// ๐Ÿ˜ฑ
async function getUserPosts(userId, category, date, archived) { 
    return service.getPosts(userId, category, date, archived)
}
// ๐Ÿ™
function getUserPosts(getPosts) {
    return async function(searchParams) {
        return getPosts(searchParams)
    }
}

I'm not saying I like having many parameters to begin with, but if I have to, I try wrapping them into an object. If my function has four or more parameters, I reach for an object instead. It has a nice side effect of readability when you have optional or boolean parameters.

Avoid long functions

If you try to keep your functions focused with limited responsibility, this will almost be automatic. My personal preference is having the function fit on the screen. This usually means around 20-30 lines.

Be explicit with error handling

// ๐Ÿ˜ฑ
function validateAge(age: number): { msg: string } {
    if (age < 0) throw new Error('Completely unexpected!')
    if (age > 70) throw new Error('Ok boomer...')
    if (age > 30) throw new Error('Still too old!')
    return { msg: "It's all good!" }
}
// ๐Ÿ™
function validateAge(age: number): ConceivableError | Success {
  if (age < 0) throw new Error('Completely unexpected!')
  if (age > 70) return 'boomer'
  if (age > 30) return 'still-too-old'
  return { msg: "It's all good!" }
}

Silly example, and one of the principles I haven't yet fully landed in. But take a look at the first function definition. It's hard to understand what can go wrong inside the function. In the second example, we've at least defined some of the more common error states. You can play around a lot with this type of error handling and go for something like the Either type, which you usually find in more functional languages.

Please share if you have any good principles of your own that you think can be helpful!

ย