The simplest way is to burn a property index for each direction, but that’s 10-12 properties consumed right there.
On the other hand, it can be fairly efficient because on v3 properties can be either 1 byte or 2 bytes. A 1 byte property will always be a room index, and a 2 byte property will always refer to a string or a routine.
Property 4 on a room could mean “connection to the north” and property 4 on an object could probably mean something completely different as well.
Of course there’s nothing preventing you from having a property point at a table of (direction, destination) pairs either? Or just use v4+ and have properties of up to 64 bytes store (direction, destination) pairs directly. Directions only need four bits, and rooms can probably fit in twelve bits, but you’d need a different encoding entirely to represent string/routine addresses.
Inform requires that you can tell an object number, a routine address, and a string address apart. In the veneer code I see there’s some support for using the LSB to disambiguate a string and a routine. In some ways that cuts the maximum number of either in half, but also it’s less code and you could conceivably interleave routines and packed strings together to minimize wasted space. Assuming v5, if your routine is 4N+1 through 4N+4 bytes long, find a string to put after it. Otherwise, put another routine after it.
But the more common way seems to be to store all routines and strings separately, at which point you can remember the cutoff point. What bugs me about that though is unsigned compares don’t natively exist, and the veneer code expands to several instructions every time.
Having said all that, using the LSB does seem really simple and has minimal overhead.
Another option is instead of needing to tell routines and strings apart – just always use routines. A routine that only calls @print_ret only needs two bytes of overhead (the zero parameter count, and the opcode). (In that case, a return value of zero might produce “You can’t go that way” and object 1 cannot be a valid room destination)
I didn’t dig far enough into the ZIL code to see how it does it, although I assume the versions that test a bit or flag implicitly generate a routine call on your behalf.