writing

TIL: Justfiles Instead of Makefiles

Sometimes I just want a short name for a long command. Justfiles give project commands a home, with built-in help, recipe parameters, and no .PHONY declarations to remember.

Michael Movsesov
Michael MovsesovCo-authored with ChatGPT

You want one command to start your app, another to run the linter, and maybe a third to bring up Docker. I like having those commands written down somewhere β€” preferably somewhere I can run them, too.

A Makefile is a familiar place to put them. But if you've ever wondered why your little collection of shortcuts needs something called .PHONY, there's a whole build-system model hiding underneath that question. A lot to unpack when you just wanted to start the server. πŸ˜…

Today I learned about just, a command runner that stores those shortcuts in a file called a justfile. Think of it as a small panel of labeled buttons for your project: start this, check that, bring these services up.

Why is Make asking about files?

Makefiles have been a fixture in development for decades, particularly around C and C++ projects. Make knows how to build files from other files, using dependencies and timestamps to decide which work needs doing.

If you mostly work with tools that handle compilation for you, that model can feel a little sideways. You're thinking β€œrun this command.” Make is thinking β€œdoes this target need updating?”

Here's a small Makefile:

.PHONY: lint
 
lint:
	npm run lint

Without .PHONY, an existing file or directory named lint could cause Make to consider this target up to date and skip the command. Declaring it phony tells Make that lint names an action. The GNU Make manual explains this collision with a similar example.

That behavior makes sense once you understand the model. But for a panel of buttons, I'd rather each button already mean β€œdo the thing.”

Let's give those commands a home

First, install just. On macOS with Homebrew:

brew install just

The installation docs cover other package managers, too.

Then create a file named justfile in your project root. For a Node project with dev, lint, and build scripts in its package.json, we could write:

# Start the development server
dev:
    npm run dev
 
# Check the code for lint errors
lint:
    npm run lint
 
# Build the app after linting
build: lint
    npm run build

Each named block is a recipe. The indented lines are shell commands, and build: lint says to run the lint recipe before the build. If linting fails, the build won't run.

Try one:

just dev

Or run the build, including its lint dependency:

just build

Running bare just executes the first recipe by default β€” dev here. Those are the basic pieces from the quick start.

Notice what's missing? No .PHONY. A file named lint won't get in the way. You can also indent recipes with spaces, as above; Make traditionally requires a tab before recipe commands. These are deliberate differences from Make.

The comments become the menu

This is my favorite part. Those comments above the recipes aren't only useful when you're reading the file. Run:

just --list

And you get:

Available recipes:
    build # Build the app after linting
    dev   # Start the development server
    lint  # Check the code for lint errors

The comments become recipe documentation, and the list is sorted alphabetically by default. You put a label above each button, and just builds the menu for you. That's so nice. ✨

You can build a help target in a Makefile, of course. Here, that behavior comes built in, with no comment-parsing script to maintain.

A button can take an argument, too

Recipes can have parameters and default values. Add this to a project that uses Docker Compose:

# Follow logs for a Compose service
logs service='web':
    docker compose logs --follow {{service}}

Now you can write:

just logs
just logs api

The first uses web; the second uses api. The {{service}} expression inserts the parameter into the command. Here we're passing simple service names.

Make can accept command-line variable assignments, such as make logs SERVICE=api. What I like about just's version is that the parameter sits right beside the recipe name, almost like a function signature. You can see what the command takes without hunting through its body.

Where I'd reach for it

For a Node-only project with a few npm scripts, I'd happily keep those scripts. A justfile gets more interesting when the commands span several tools: npm for the frontend, Docker for services, a Python script for some data work. One little control panel for all of it.

There's a tradeoff: contributors and CI need just installed. And just doesn't do Make's file-based incremental builds. If you ask it to run a build recipe, it runs the command; any decision to skip unchanged work belongs to the tool that command invokes. That's part of its command-runner design.

So I'd keep Make where its dependency tracking is doing useful work. For a Makefile full of everyday project shortcuts, though, a justfile feels like a lovely fit.

Start with two commands you keep looking up, give them names and comments, and run just --list. Having a little menu waiting for you the next time you open the project is a small comfort. I'll take it. πŸ˜„