That might seem okay if you deliberately unset a variable. But I'll point out that the same thing happens with variable names that are garbage, in all Redbols:
Ren-C's full answers to binding issues are beyond the scope of this post. But it does point to the idea that having too few states in play can lead to undesirable results.
It may be interesting to know that early Ren-C's focus was not on separating the states. So it also only had one state for this... though the name was changed to "VOID".
The principal difference was on ensuring that VOID could never be found in a BLOCK!. It was a state that could only be held by variables. This was to try and fix up inconsistencies in historical Rebol... where some code paths let you put the unset state in blocks, others not, representational issues, etc.
rebol2>> compose [a (get/any 'asdfasdf) b]
== [a b]
rebol2>> append [a b c] reduce [get/any 'asdfasdf]
== [a b c unset]
But beyond preventing VOID in blocks, not much else changed. Failed branching constructs produced this state as well. So when an IF didn't take its branch, you could use it to unset a variable... as well as to make COMPOSE slots vanish. It didn't print in the console either!
historical>> x: if 1 > 2 [<math-is-broken>] ; prints nothing on next line
historical>> x
** Error: whatever the error for an unset variable is
historical>> compose [a (if 1 > 2 ['b]) c]
== [a c]
And then there was the LIT-VOID!. This was what I thought made perfect sense, to make it single-quoted nothingness:
historical>> x: '
historical>> x
** Error: x is VOID
So VOID wasn't the tick mark... it was what you got when you evaluated the tick mark. This fit in with things like LIT-WORD! and LIT-PATH!, where the evaluator drops one level of quoting. But here dropping that level gives you the "missing" state. Having the console show no output seemed to fit in perfectly.
Honestly, it is kind of clever... IF this were as far as the system was going to go...
What Upended This "Clever" Idea?
The rise of generalized isotopes meant there wasn't merely a single state that couldn't be put in blocks...but a whole menagerie of antiforms.
To fit into this menagerie correctly, the state that evaluated to produce an unset variable had to be a quasiform to make an antiform...not a quoted. So unsetting a variable became done with what was chosen to be the quasiform of the "SPACE RUNE!"... a lone tilde:
>> quasi _
== ~
>> x: ~
>> x
** Error...
That quasiform evaluates to the antiform, which has no representation. Though it can be written out as ~ ; anti it could also be purple, or anything you like... but the console just said this would display nothing.
First Reason For The TRASH / VOID Split Was Safety
Antiform blank continued to do double-duty, as the contents of an unset variable... but also the return value of a function with a "meaningless" result.
So functions like HELP and PRINT returned antiform blank.
This "meaningless" intent--ornery when accessing via variables from words--led it to take on the new name of TRASH.
Functions like APPEND or COMPOSE came to reject TRASH by design:
>> append [a b c] print "This should error"
This should error
** Error: APPEND doesn't allow TRASH (antiform ~) for its VALUE argument
It seemed clear that the "erasing" intent needed to be a different antiform. That antiform took the name of VOID.
Second Reason For The Split Was Needing Unstable VOID
If the arguments for safety aren't convincing, there's a very important distinguishing characteristic of VOID... which is that it's an unstable antiform. It's actually an empty antiform multi-return PACK!.
Remember that PACKs can't be stored in variables directly (you have to use a meta-representation). So antiform packs will decay to their first element in plain assignment:
>> pack [1 2]
== ~['1 '2]~ ; anti
>> x: pack [1 2]
== ~['1 '2]~ ; anti
>> x
== 1
But functions which take "meta-arguments" are able to do more processing on the pack. SET-BLOCK! is one of the places that does this additional processing, and can unpack the elements:
>> [y z]: pack [1 2]
== ~['1 '2]~ ; anti
>> y
== 1
>> z
== 2
Yet VOID is defined as a PACK with no elements in it at all. Which means you can't assign it to ordinary variables... and in fact you get an error if you try.
>> void
== ~[]~ ; anti
>> x: void
** Error: No values to unpack in antiform ~[]~ PACK! (VOID)
This distinguishing aspect separates it clearly from TRASH--since we know trash must be able to be held in a variable to mark it as "there, but not set to an meaningful value yet".
>> m: to map! [a 10 b 20]
>> m.a: void
== ~[]~ ; anti
>> m
== #[map [b 20]]
There's competition in situations like this between "mapping to trash" and "not being there at all". And the unstable antiform state makes it cleanly suited to whenever you want to capture the state of not being there at all.
So... Is It Worth It?
IN THE CURRENT DESIGN, I CAN UNEQUIVOCALLY SAY YES.
But it wasn't always quite perfect. For some time, empty pack was used to implement GHOST... and VOID was just another antiform WORD! (~void~) that could be stored in variables... just like TRASH.
I could tell from my own day-to-day usage that this "stable void" earned its keep as a distinct type from TRASH. But being able to be stored in variables led to some tough-to-answer questions about its behavior. For instance: was it a good or bad idea to allow direct access to ~void~ via WORD!, or should it error like TRASH ?
Or consider something like DEFAULT. It was defined such that it would overwrite null and trash variables:
But what about ~void~ antiforms? Were those also things that had to be overwritten? It wasn't clear.
Basically, with VOID as a stable antiform it wound up creating the very combinatoric questions one might be concerned about from having "too many types". Though it was different in name, its fundamental properties weren't different enough to make decisions flow obviously about how its handling should be different from TRASH or NULL.
Yet a crucial change of GHOST! to being the antiform of COMMA! freed up the empty antiform BLOCK! state to be a non-vanishing, unstable VOID antiform.
Confusion disappeared:
There's no question about whether it should create errors to try and access VOID from words, because you can't store a void in a variable at all.
You don't have to decide if VOID is a "defaulting" state or not, because you can't store void in a variable at all.
etc. This pulled everything together, and the clarification in how decisions are able to flow now is rather mind-blowing.