Visual walkthrough — CMake — CMakeLists.txt, add_executable, add_library, target_link_libraries
Prerequisites we lean on, each linked so you can refresh: Compilation Pipeline preprocess-compile-assemble-link (why headers and linking are two separate problems), Header files and Include Guards (what an "include path" is), Static vs Dynamic Linking (what "link" means physically). The parent is the CMake topic note.
Step 1 — Two completely separate needs: find the header vs pull in the code
WHAT. Before any CMake keyword makes sense, we must see that building a program that uses a library needs two independent favours, not one:
- an include path — a folder the compiler adds to its search list so that
#include "add.h"resolves to a real file. This is a compile-time, text thing. - a link edge — an instruction to the linker: "the machine code for
add()lives inlibmathlib.a, glue it in." This is a link-time, binary thing.
WHY split them? Because in Compilation Pipeline preprocess-compile-assemble-link these happen at different stages. The preprocessor needs the header; the linker needs the compiled object. You can have one without the other, and CMake tracks them as two separate streams.
PICTURE. The mint arrow is the include path; the coral arrow is the link edge. They start at the library and must both reach the program.

Step 2 — One library, one program: the base case with no propagation yet
WHAT. Take the parent's example. A library mathlib (built from math/add.cpp, header in
math/) and a program calc that calls a function from it.
add_library(mathlib STATIC math/add.cpp) # (A) make a linkable unit
target_include_directories(mathlib PUBLIC math) # (B) mathlib owns the folder math/
add_executable(calc app/main.cpp) # (C) make a runnable unit
target_link_libraries(calc PRIVATE mathlib) # (D) calc depends on mathlibWHY line (B) says PUBLIC? Look at the file add.h. It is not private scratch code inside
mathlib — it is the doorway through which calc talks to mathlib. So the folder math/
must reach both mathlib itself (to compile add.cpp, which includes add.h) and everyone who
links mathlib. "Both me and my users" is exactly the meaning of PUBLIC.
WHY line (D) says PRIVATE? calc is an executable. Nothing in the world links against an
executable — it is the end of the line. So there is no "user" to propagate anything to. The minimal,
honest choice is PRIVATE.
PICTURE. Watch the mint include-path arrow leave mathlib (opened by PUBLIC at B) and arrive
at calc, alongside the coral link edge created by (D).

Step 3 — Add a third target: now we have somewhere to propagate to
WHAT. The magic of the keywords is invisible with only two targets, because an executable has no users. So introduce a chain of three, which is the parent's Section 4 example:
add_library(util STATIC util.cpp) # bottom
add_library(core STATIC core.cpp) # middle — uses util
add_executable(app app.cpp) # top — uses coreWHY three? Propagation is about the middle target passing (or blocking) what came from below.
You need a below (util), a middle (core), and an above (app) to even ask the question.
PICTURE. Three boxes stacked. Below sits util with its own include folder and its own machine
code. The question marks on the two upward arrows are what Steps 4–5 resolve.

Step 4 — The PUBLIC valve: core's header exposes util, so util flows all the way up
WHAT. Suppose core.h itself contains #include "util.h" — a struct from util appears in a
function signature that core shows to the world. Then:
target_link_libraries(core PUBLIC util) # core's HEADER exposes util
target_link_libraries(app PRIVATE core)WHY does util reach app? Trace it. When app includes core.h, the preprocessor immediately
hits #include "util.h". For that to succeed, app's compiler command must contain util's include
path. And since util's struct becomes part of core's compiled interface, app must also link
util's machine code. Because core → util is PUBLIC, the valve is open: both streams flow
through core and continue up into app. This is precisely why the parent's Forecast answered ✅.
PICTURE. The PUBLIC valve on the core→util arrow is open (mint + coral pass straight through
core into app). The green tick confirms app links util transitively.

Step 5 — The PRIVATE valve: util is sealed inside core's .cpp, so app never sees it
WHAT. Now flip one keyword. Say util is used only inside core.cpp — no mention in
core.h:
target_link_libraries(core PRIVATE util) # util hidden in core's implementation
target_link_libraries(app PRIVATE core)WHY does util's include path stop at core? Because app never includes anything that
pulls in util.h. Handing app the folder util/ would be pointless leakage. So the PRIVATE
valve blocks the mint arrow — app's compiler command does not get util's include path.
This is the correct outcome: app genuinely does not need util's headers.
A subtlety about the coral link arrow (STATIC case). With static libraries, the machine code
of util may still have to be physically present at final link time (the parent's
Static vs Dynamic Linking explains why static code gets copied in). CMake handles that ordering
automatically. But the crucial usage-requirement — the include path and any compile flags — is
what PRIVATE seals off. From the programmer's mental model: app cannot #include "util.h",
and that's exactly what we wanted.
PICTURE. The PRIVATE valve is shut: the mint include arrow is chopped off at core; a red
✗ marks that app has no visibility of util.h.

Step 6 — The INTERFACE valve: a target that gives everything and uses nothing
WHAT. A header-only library has no .cpp — there is nothing to compile, only headers to
hand out. In CMake:
add_library(tinyjson INTERFACE) # no sources at all
target_include_directories(tinyjson INTERFACE include) # give the folder to USERS only
add_executable(app app.cpp)
target_link_libraries(app PRIVATE tinyjson) # app receives tinyjson's include pathWHY INTERFACE and never PUBLIC here? PUBLIC = "me and my users." But tinyjson
compiles no code of its own — there is no "me" that needs the folder. Everything it owns exists
purely to be given away. INTERFACE = "not me, only my users." Marking it PRIVATE would keep
the flags for a "me" that doesn't exist, so nobody would ever get them — the header-only library
would silently do nothing (the parent's fourth mistake).
PICTURE. tinyjson is a hollow box (no compiled code, drawn dashed). Only the mint include-path
arrow leaves it, opened by the INTERFACE valve, and lands in app.

Recall Which valve for which situation?
Header exposes the dependency (it appears in your .h)? ::: PUBLIC
Dependency used only inside your .cpp? ::: PRIVATE
You are header-only, so you have no .cpp at all? ::: INTERFACE
Step 7 — Degenerate cases, so no scenario surprises you
WHAT & WHY, one at a time:
- The lone executable (Step 2, target
calc). It has no downstream users, soPUBLICandPRIVATEon its link are indistinguishable in effect — but writePRIVATEas the honest, minimal choice. - A diamond. If
applinks bothcore(PUBLIC→util) andutildirectly,utilis requested twice. CMake de-duplicates — it appears once in the final link, no double-copy, no error. - Cycle attempt. If
AlinksBandBlinksA, CMake reports an error, because the dependency graph must be acyclic to compute a build order (there is no "who first?"). - Missing target.
target_link_libraries(app PRIVATE nolib)wherenolibwas never created fails at configure time — CMake cannot find the node in its graph. - INTERFACE with sources.
add_library(x INTERFACE x.cpp)is illegal:INTERFACEmeans no build output, so a source file contradicts it.
PICTURE. Left: a diamond, showing util reached by two paths but drawn once at the final link
("de-duplicated"). Right: a cycle marked with a red ✗ and "no valid order."

The one-picture summary
Everything above compresses into a single valve diagram: three targets stacked, and each link is a
valve. PUBLIC opens both streams onward; PRIVATE blocks the include stream at that node;
INTERFACE emits streams from a hollow (source-less) box. The mint arrow is where the compiler
looks for headers; the coral arrow is what the linker pulls in. That is the entire propagation
model of modern CMake.

Recall Feynman retelling — say it like a story
Two favours travel through a project: a header path (so #include finds the file) and a link
edge (so the linker finds the compiled code). Every dependency you declare is an arrow, and every
PUBLIC/PRIVATE/INTERFACE keyword is a valve on that arrow. If your header mentions a
dependency, your users will trip over it too, so you must let it flow onward — that is PUBLIC.
If only your hidden .cpp uses it, you keep it to yourself — that is PRIVATE, and it correctly
stops the header path from leaking upward. If you are header-only, you have no code of your own,
so you keep nothing and give everything — that is INTERFACE. Executables are the top of every
chain, so their keyword never propagates; write PRIVATE and move on. CMake reads all these
arrows as a graph, removes duplicates, refuses cycles, and computes the link order for you — which
is why, unlike raw g++, you never write -lcore -lutil in the right order by hand.