Visual walkthrough — SFINAE — substitution failure is not an error
We assume you have read SFINAE — substitution failure is not an error for the idea. Here we do the construction.
Step 1 — What a "candidate" even is
WHAT. Picture two candidates lined up like résumés on a desk.
WHY. SFINAE only ever looks at the header. So before anything else, we must be able to see where the header ends and the body begins — because that line is the entire boundary of the rule.
PICTURE. The signature is the shaded band at the top of each card; the body is the plain box below it. SFINAE's "eye" only scans the shaded band.

Step 2 — Substitution: plugging the type into the header
Start from the tiniest possible template:
template <typename T>
void probe(const T& t);WHAT. We deduced T from the argument, then rewrote the header with the real type pasted in.
WHY this tool and not a runtime if? Because this happens at compile time, before any program runs. An if chooses a path while the program runs; substitution chooses which candidate even exists before the program is born. That is the only way to ask "does this type have a member?" — the answer must be known before the code is compiled.
PICTURE. The placeholder T is a hole in the header; substitution drops the concrete type into the hole.

Step 3 — Forcing a check into the header with decltype
We do not just want .size() to work in the body — we want its validity to be part of the header, so that a failure gets the candidate tossed (Step 1's shaded band) instead of crashing.
WHAT. We buried a test expression, t.size(), inside the return type.
WHY decltype and not, say, calling t.size() in the body? Because the body is outside the shaded band — a failure there is a hard error (parent's first mistake box). decltype lives in the return type, which is the header. That is the whole game: move the check into the SFINAE region.
PICTURE. The test expression sits inside the shaded header band; SFINAE's eye can see it.

Step 4 — The fork: success vs. failure
Now feed two different types into the same template and watch the header.
WHAT. One template produced a living candidate for vector, and no candidate at all for int.
WHY. This is SFINAE doing its one job: a failure in the shaded band is not an error, it is a deletion. The type itself decided whether the candidate exists.
PICTURE. A road forks: the green branch (valid header) keeps the candidate; the magenta branch (invalid header) drops it into the bin — no crash, just gone.

Step 5 — Adding a fallback so the answer is always "yes or no"
If getSize(7) deletes the only candidate, you get "no matching function" — a hard error. We do not want an error; we want the answer "no, it has no size". So we add a second candidate that always works but is always the worst match.
WHAT. We guaranteed there is always at least one surviving candidate.
WHY the ... and not another normal overload? Because two equally-good survivors would be ambiguous (parent's third mistake). ... is deliberately the lowest priority, so it never competes — it only catches the leftovers.
PICTURE. Two ranked lanes: the fast lane (A) is open only for types with .size(); everyone else is funneled into the slow lane (B).

Step 6 — Turning the fork into a bool (the detection idiom)
We do not really want the size — we want the fact "size-able: yes/no" as a compile-time boolean. Same machinery, packaged as a type trait.
template <typename T>
auto test(int) -> decltype(std::declval<T>().size(), std::true_type{});
template <typename T>
std::false_type test(...);
template <typename T>
struct has_size : decltype(test<T>(0)) {}; // 0 prefers int over ...WHAT. The choice between two candidates got compressed into a single true/false value.
WHY pass 0? 0 is an int, and int matches test(int) better than test(...). So the int overload is tried first; only if its header is deleted do we fall to .... The whole survival-vs-deletion drama collapses into one bit.
PICTURE. The fork of Step 4 is squeezed through a funnel that outputs a single lamp: green lamp = true, magenta lamp = false.

Step 7 — Edge & degenerate cases you must not miss
WHAT. Four ways the naive picture breaks, each with its own fix.
WHY. SFINAE is precise: it protects the shaded band and nothing else. Every trap above is a place where you accidentally step outside the band, or where two survivors collide.
PICTURE. A map with three danger zones marked: "body zone" (red — crashes), "overlap zone" (orange — ambiguous), and the safe "header zone" (green).

The one-picture summary

The whole derivation on one canvas: a type enters at the left. It is substituted into two headers. The .size() header either survives (green) or is deleted (magenta) inside the SFINAE band. The variadic ... header always survives but ranks lowest. Overload resolution picks the survivor with the best rank, and the choice is read off as a single bool.
Recall Feynman: the whole walkthrough in plain words
We wanted to ask a computer a yes/no question before the program runs: "does this type have a .size()?" A normal if can't do that — it decides while running. So we used the compiler's own résumé-tossing rule. First we wrote a function whose header contains a tiny test — decltype(t.size(), ...) — so the test lives in the part the compiler inspects (the header, not the body). If the type has .size(), the header makes sense and the résumé stays on the desk. If it doesn't, the header is gibberish and the résumé is quietly binned — no crash. Then we added a lazy backup résumé (...) that always applies but always loses, so there's always a survivor. Finally we made the two résumés return true and false, so which one wins simply is the answer. Edge cases: keep the test in the header (never the body), never let two good résumés tie, and use declval so even types you can't construct still get tested. That's the detection idiom — SFINAE turned into a compile-time question machine.
Connections
- SFINAE — substitution failure is not an error — the rule this page derives from
- decltype and Trailing Return Types — the tool that puts the check in the header
- std::enable_if and Tag Dispatch — the boolean-gate variant of the same fork
- Type Traits (std::is_integral, std::void_t) — packaging the answer as a trait
- void_t Detection Idiom — the cleaner modern spelling of Step 6
- Overload Resolution — why
...always loses and0prefersint - Concepts (C++20) — the readable replacement for all of this