Previously we just always evaluated both operands of any binary operator
and then executed the operator. For the logical operators that was
inconsistent with their treatment in several other languages with a
similar grammar to ours, leading to users being surprised that HCL didn't
short circuit the same way as their favorite other languages did.
Our initial exclusion of short-circuiting here was, as with various other
parts of HCL's design, motivated by the goals of producing consistent
results even when unknown values are present and of proactively returning
as many errors as possible in order to give better context when debugging.
However, in acknowledgement of the fact that logical operator
short-circuiting has considerable ergonomic benefits when writing out
compound conditional expressions where later terms rely on the guarantees
of earlier terms, this commit implements a compromise design where we
can get the short-circuit benefits for dynamic-value-related errors
without losing our ability to proactively detect type-related errors even
when short-circuiting.
Specifically, if a particular operator participates in short-circuiting
then it gets an opportunity to decide for a particular known LHS value
whether to short-circuit. If it decides to do so then HCL will evaluate
the RHS in a type-checking-only mode, where we ignore any specific values
in the variable scope but will still raise errors if the RHS expression
tries to do anything to them that is inconsistent with their type.
If the LHS of a short-circuit-capable operator turns out to be an unknown
value of a suitable type, we'll pessimistically treat it as a short-circuit
and defer full evaluation of the RHS until we have more information, so
as to avoid raising errors that would be guarded away once the LHS becomes
known.
The effect of this compromise is that it's possible to use the
short-circuit operators in common value-related guard situations, like
checking whether a value is null or is a number that would be valid
to index a particular list:
foo != null && foo.something
idx < 3 && foo[idx]
On the other hand though, it is _not_ possible to use the behavior to
guard against problems that are related to types rather than to values,
which allows us to still proactively detect various kinds of errors that
are likely to be mistakes:
foo != null && fo.something # Typoed "foo" as "fo"
foo != null && foo.smething # Typoed "something" as "smething"
num < 3 && num.something # Numbers never have attributes
Those coming from dynamic languages will probably still find these errors
surprising, but HCL's other features follow a similar sort of hybrid
model of statically checking types where possible and that tradeoff seems
to have worked well to make it possible to express more complicated
situations while still providing some of the help typically expected from
a static type system for "obviously wrong" situations. It should typically
be possible to adjust an expression to make it friendly to short-circuit
guard style by being more precise about the types being used.