Software EngineeringBest Practice

Why you should adopt Makefile in all of your projects

GNU Make. A software that is, most likely, older than you. It’s so simple, so standard, and so ignored. I’m here to provide a case in favor of make and Makefiles.

What is make and Makefile?

Before I even start to build my case, I need to explain to you what is make and Makefile.

make — is a build automation tool. It is written in C and was first released in April 1976. On Linux, make is usually available as part of the build tools dependencies that includes stuff like gcc — the C compiler etc. On Ubuntu, it's under build-essentials; on Arch Linux, under base-devel. MacOS users can install it with brew. And on Windows… I honestly have to clue what is going-on on Windows.

But make does not work by itself. It needs instructions. And those instructions are stored in a Makefile.

Makefile 101

Makefile is a file named… Makefile. Makefile consists of rules. Each rule has a target, optional dependencies (which can be other targets or files — more on that later) and a set of commands.

A simple Makefile that complies index.ts into index.js might look like this:

build:
	npx tsc index.ts

In order to compile the project, you execute make with a target name:

make build

build — is the name of the target. Then each next line that starts with a tab is a command that make will execute. The above example will compile index.ts and place the resulting index.js in the same folder. I can modify the commands and move the resulting file into dist directory:

build:
	mkdir -p dist
	npx tsc index.ts
	mv index.js dist

Of course, in case of TypeScript — you should avoid this practice and instead set up a proper tsconfig.json, since TypeScript compiler handles that for you. But for the sake of demonstration, I’ll continue with this example as it’s easier to understand.

I’ve mentioned earlier that make targets can have dependencies which might be other make targets or files. Let’s explore this concept. Say we have the following directory:

~/workspace/make_test ❯ tree -I node_modules
.
├── Makefile
├── package.json
├── src
│   └── index.ts
└── yarn.lock

1 directory, 4 files

A typical TypeScript project. We want to compile it with make and put the resulting files in dist folder (which might or might not exist and might or might not have previously compiled files). Here’s a Makefile that allows this.

prepare:
	mkdir -p dist

clean: prepare
	rm -rf dist/*

dist/index.js: src/index.ts
	npx tsc $< --outfile $@

build: clean dist/index.js

When I run make build, I get the following output:

~/workspace/make_test ❯ make build
mkdir -p dist
rm -rf dist/*
npx tsc src/index.ts --outfile dist/index.js

And running tree reveals that I now have a compiled file in dist directory.

.
├── Makefile
├── dist
│   └── index.js
├── package.json
├── src
│   └── index.ts
└── yarn.lock

2 directories, 5 files

Let’s understand step by step what’s going on.

First, we have a target named prepare. This target has only one command that creates a directory named dist.

Then, we have a target named clean. The purpose of this target is to delete the contents of dist. But if you’ve noticed, unlike prepare which does not have anything after the double colon, clean specifies prepare. This is called a dependency. In order to run clean, it will execute prepare first.

Then we have another target called dist/index.js. This time it’s a file, and it depends on… you’ve guessed it — another file called src/index.ts. Targets in make, as I’ve said earlier, can depend on other targets or on files. In order to satisfy the file dependency, make makes sure that the file exist. Make does not really differentiate between targets and files. If you create a file named prepare and run make build again, you can see that this time the output is different:

~/workspace/make_test ❯ make build
rm -rf dist/*
npx tsc src/index.ts --outfile dist/index.js

Make is no longer executing mkdir -p dist, because make sees that a file named prepare already exists, therefor this target is already satisfied. The same is true for src/index.ts. In practice — it’s a target. But our Makefile does not have a definition for that target. If you go on and delete (or rename) src/index.ts, and run make build again — you will get an error:

~/workspace/make_test ❯ make build
mkdir -p dist
rm -rf dist/*
make: *** No rule to make target `src/index.ts', needed by `dist/index.js'.  Stop.

Because make can’t find the file src/index.ts, nor it can find a target named src/index.ts in our Makefile — it fails. Remember — make xyz first checks for the existence of a file named xyz, and if no such file is found — it looks for a target named xyz, and if none found — make fails. Also if you’ve noticed — make does not do rollbacks. It simply executes commands until it’s done or until failure.

Side note: there is a way to instruct make that a target is not a file by using what is known as phony targets, but for the sake of simplicity, I’m not going to cover that here. You can read more about phony targets if you want.

Going back to our dist/index.js. This target executes one command npx tsc $< --outfile $@. There are two variables there: $< — is the name of the first dependency, in our case src/index.ts while $@ is the name of the target (dist/index.ts). And so running this command translates to npx tsc src/index.ts --outfile dist/index.js.

Make has a robust mechanism for variables and looping over files. You can, for example, pick all the *.ts files and compile each and one of them using the $< and $@ parameters. I’m not going to cover it here since it’s not a tutorial about make. Drop me an email if you want me to write one (my contact details are at the bottom of this post).

Finally, we have the build target. This target depends on two other targets — the clean target and the dist/index.js target, and by itself it executes no commands.

Makefile has more cool features such as support for variables and macros, including sub-makefiles etc. For the sake of simplicity, I’m not going to cover them here. You can take a look at my decade old Makefile for a WIP OS I was once writing. It includes variables, macros, sub-makefiles etc.

But… but… this looks like [your favorite build tool name]

Exactly! If you are familiar with JavaScript then you are probably familiar with npm scripts. In Rust we have cargo. In Go, we have the go build tool. When using Python, we have pip, pipenv, potery and conda for dependency management. For Java there are maven, gradle and ant. In Ruby, dependencies are managed with gem. And honestly — it’s too much.

Here’s the thing. Developers are lazy. When you jump from project to project — managing dependencies and compiling the code becomes a headache. Even if you don’t change the language, only in JavaScript we had grunt, gulp and bower which are dead long ago, but might still be in use for some legacy project you are working with; but even today we are torn between npm and yarn, and I didn’t mention frontend where there are multiple bundlers to choose from. I tend to jump between projects written in JavaScript, TypeScript, Java, Python, Ruby, and Rust. So I need to remember a handful of script runners and dependency management tools.

And I don’t want to do 2 things: (a) Write a comprehensive README.md file that describes how to install, build and run my project, and (b) Read a comprehensive README.md file that describes how to install, build and run your project. I want to be productive by simply jumping into the codebase and producing code, rather than spending my time on trying to figure out what dependency management system the project uses and how to run tests.

And here’s where make and Makefile comes in. make exists for 46 years. It’s not going away. By providing Makefile and common make targets like build, compile, lint and test — I can easily jump into a project and start being productive. I don’t need to scout multiple files, trying to come up with the winning combination. Aha! You have a package.json, but a yarn.lock so this means I need to use yarn install. It’s a mental overhead I don’t want to deal with.

Moreover, designing a proper CI pipeline becomes a breeze. My CI will execute make targets, and under the hood I can use whatever dependency management / script runner tool that I like. Hell, I can even change them every 5 days when a new hyped, blazingly fast, build / script runner tool appears.

Use whatever you like. You like yarn? Go ahead! Use yarn. You can even put your commands in package.json if you want to (for example to utilize pre* or post* hooks), but please provide a Makefile that acts as a facade to your npm scripts. That way, all we need to look at is only one single Makefile to understand how to run, build, compile and test the project.

Share this article

Published by

Dmitry Kudryavtsev

Dmitry Kudryavtsev

Senior Software Engineer / Tech Lead / Consultant

This post shares my opinions and is not meant to represent the opinions and views of my employer, my family, and or my colleagues.