OK, as requested, here is a run-through of what happens in the TeX program, in the case of \def
and \edef
respectively. For this question it adds absolutely nothing over the basic point (that \noexpand
, \expandafter
etc. are indeed “expandable”), but it may be interesting to you anyway.
You will want to refer to the TeX program as you read the following.
What TeX's main_control
procedure (section 1030) does is to read a token (get_x_token
) in (essentially) a loop, and decide what to do. The inner loop is tightly optimized for regular characters in horizontal mode, but things like \hrule
or \def
are not part of it (→ section 1045), and in fact \def
and \edef
are part of mode-independent processing (→ section 1210), and result in a call to the procedure prefixed_command
(section 1211). This procedure is common to many primitives/commands, but for both \def
and \edef
, it is called with:
cur_cmd
= [a code indicating "def"], and
cur_chr
= [0 for \def
, 1 for \gdef
, 2 for \edef
, 3 for \xdef
]
(As you may guess from these four numbers, Knuth later checks "odd(cur_chr)
" to decide whether the current definition should be global or not, and "e = (cur_chr >= 2)
" to decide whether to expand or not!)
Anyway, in this prefixed_command
procedure (section 1211), the execution falls into section 1217 then 1218, which is:

Suppose your input had \def\foo{\bar}
or \edef\foo{\bar}
. Then in the above, get_r_token; p ← cur_cs;
would set p
to (basically) \foo
, and then scan_toks(true, e)
is called.
Here, of the two parameters to scan_toks
, the first one (macro_def
, here passed as true
) says that the token list to be scanned is that for a macro definition (as opposed to the token list for a \mark
, \output
, \message
, \everypar
, etc.), and the second boolean (xpand
, here passed as e
) decides whether to expand or not.
So let's dive into scan_toks
(section 473).

First, as we're in macro_def
, it goes into ⟨Scan and build the parameter part of the macro definition⟩
, section 474. In our example (parameterless macro), all that happens is that get_token
gets token begin-group character {
, and therefore this part simply ends with end_match_token
being added to the token list. The token list for the actual body of the definition is scanned in section 477.
This one basically gets tokens one-by-one and adds them to the token list being built (for the body of the definition), with the crucial difference that in the case of \edef
or \xdef
, the tokens get expanded as we go along:

In the case of \def\foo{\bar}
there is no complication of expansion and this part of the token list contains basically just the token \bar
; so after scan_toks
returns, the definition of \foo
is saved as the token list containing two tokens, end_match
and \bar
.
In the case of \edef\foo{\bar}
, this part (remember we're still inside scan_toks
) reads tokens from the input stream (currently containing \bar
) one at a time, each time expanding any token it sees. Specifically, the expansion here happens in section 478's call to the procedure expand
(section 366). The way this procedure works is that each time it is called, it (essentially) simply destroys the first token in the input stream, and replaces it with the “expansion” (whatever is appropriate) of the removed token. This way, the next calls to get_token
(say) will pick up the replaced tokens.
As you can see, here “expand” / “expansion” / “expandable” means basically anything that has a replacement (i.e. should be changed somehow), which does not mean only macros, and includes things like \noexpand
and \expandafter
:

(Don't try to directly infer the expandable TeX primitives from the names of the variables code; instead see egreg's answer. For example, the primitives \number
, \romannumeral
, \string
, \meaning
, \fontname
, \jobname
are all expandable, because they all result in the command code convert
that is in the list in the code above.)
\noexpand
,\csname
and\expandafter
are expandable\noexpand
expandable, but\let
isn't? How can I tell?;-)
The TeXbook lists all expandable primitives.expand
function. (In the normal case, though things like\expandafter
can alter this behaviour.)