Summary
Supplying target_type to evaluate_expression changes the outcome of expressions that
evaluate correctly without it. Three cases, two distinct root causes:
- A. The target is applied to a subscript's operands — the receiver and the index —
rather than to the subscript's result.
- B. The target is applied to the operand
and/or discards, rather than to the one
they return.
- C. An explicit
any target is rejected outright, for any value.
Each fails with a coercion error on an expression that is valid and that the same
implementation evaluates correctly when target_type is omitted.
Versions
Reproduced on:
openjd-model 0.11.1 from PyPI (bundles openjd-expr 0.2.1, the current release)
openjd-rs main at d021564 — built from source, all three still reproduce
Case B's node coverage appears related to 2e6e1a4 ("fix(expr): evaluate operator operands
with unconstrained target type", 2026-05-18); see Root cause below.
Reproduction
from openjd.expr import ExprType, evaluate_expression as ev
# A — target leaks into a subscript's operands
ev("[10, 20, 30][0]") # -> 10 :: int (correct)
ev("[10, 20, 30][0]", target_type=ExprType("int")) # -> ExpressionError
ev("'hello'[0]", target_type=ExprType("string")) # -> ExpressionError
# B — target is applied to the operand and/or discards
ev("true and 0") # -> 0 (correct)
ev("true and 0", target_type=ExprType("int")) # -> ExpressionError
ev("null or 7") # -> 7 (correct)
ev("null or 7", target_type=ExprType("int")) # -> ExpressionError
# C — an explicit `any` target is rejected for any value
ev("1", target_type=ExprType("any")) # -> ExpressionError
ev("'x'", target_type=ExprType("any")) # -> ExpressionError
Expected vs actual
| Expression |
target_type |
Expected |
Actual |
[10, 20, 30][0] |
int |
10 :: int |
Cannot coerce list[int] to int |
'hello'[0] |
string |
h :: string |
No matching signature for __getitem__(string, string) |
true and 0 |
int |
0 :: int |
Cannot coerce bool to int |
null or 7 |
int |
7 :: int |
Cannot coerce nulltype to int |
1 |
any |
1 :: int |
Cannot coerce int to any |
The Expected column is what this same library returns for each expression with target_type
omitted, so no second implementation is needed to establish it.
The error spans point straight at the mis-targeted operand:
>>> ev("[10, 20, 30][0]", target_type=ExprType("int"))
Cannot coerce list[int] to int
[10, 20, 30][0]
^~~~~~~~~~~~ <- the receiver, not the result
>>> ev("'hello'[0]", target_type=ExprType("string"))
No matching signature for __getitem__(string, string)
'hello'[0]
~~~~~~~^~~ <- the index has become a string
>>> ev("true and 0", target_type=ExprType("int"))
Cannot coerce bool to int
true and 0
^~~~ <- the operand `and` discards
Why these are bugs
Case A. §2.1.7 gives the subscript signatures as
__getitem__(list: list[T], index: int) -> T and __getitem__(s: string, index: int) -> string.
The index is always int and the receiver is always the collection, irrespective of what the
calling context wants back. A target of int describes the subscript's result; applying it
to a list[int] receiver is a category error, and applying it to the index turns a valid int
into a string so no signature matches.
Case B. §2.1.6 is explicit that and/or are value-returning: "If a is null or
false, return a; otherwise evaluate and return b", and "they return one of their
operands, not necessarily a bool". For true and 0 the returned operand is 0, which
coerces to int without difficulty. The implementation instead coerces true — the operand
the rule discards. Same for null or 7, which returns 7 but reports a failure to coerce
nulltype.
Case C. §1.2.1 lists any as "Unconstrained type (matches anything)", and the
ExprValue notes state that ANY, UNION and UNRESOLVED "are type-level constructs used
during type checking. They do not appear as the type of a concrete ExprValue at runtime".
An any target is therefore a no-op constraint that every value satisfies; it should never be
the reason an evaluation fails.
Note also §1.3.1: the target "guides implicit type coercion" and "the expression must
produce a value of type T or a type that can be implicitly coerced to T" — a statement
about the expression's result, not about its sub-expressions.
Root cause
Cases A and B look like one defect: the set of node kinds that scope the target away from
their operands is enumerated rather than derived. 2e6e1a4 added
Evaluator::evaluate_with_target and applied the unconstrained-operand rule to BinOp,
UnaryOp, Compare and the test slot of IfExp. Subscript and BoolOp are not in that
list, and they are exactly the two shapes that still fail. Any node kind added later inherits
the same hazard.
Case C is separate — it is ExprValue::coerce having no arm for an any target, so the value
falls through to the catch-all error. It resembles 429aed0 ("accept union target_type via
match-or-coerce"), which fixed the analogous gap for union targets; any appears to have been
missed by the same reasoning. Happy to split it into its own issue if you would rather track it
separately.
Impact
target_type is the mechanism an embedding context uses to say what it expects, so these fire
in ordinary use rather than at the edges. §1.3.2 defines the target for a template's args
items as T? | list[T], which means a host evaluating template fields with the documented
targets hits case A for any expression containing a subscript, and case B for any use of or
as a null-coalescing operator — the pattern §2.1.6's own examples recommend
(Param.X or "fallback").
How this was found
Differential testing against an independent implementation of the same specification: a corpus
of expressions is evaluated by both and the results compared. These are the cases where the two
disagree and the specification supports the other implementation's answer. Happy to supply the
full corpus if it would be useful.
Summary
Supplying
target_typetoevaluate_expressionchanges the outcome of expressions thatevaluate correctly without it. Three cases, two distinct root causes:
rather than to the subscript's result.
and/ordiscards, rather than to the onethey return.
anytarget is rejected outright, for any value.Each fails with a coercion error on an expression that is valid and that the same
implementation evaluates correctly when
target_typeis omitted.Versions
Reproduced on:
openjd-model0.11.1 from PyPI (bundlesopenjd-expr0.2.1, the current release)openjd-rsmainatd021564— built from source, all three still reproduceCase B's node coverage appears related to
2e6e1a4("fix(expr): evaluate operator operandswith unconstrained target type", 2026-05-18); see Root cause below.
Reproduction
Expected vs actual
target_type[10, 20, 30][0]int10 :: intCannot coerce list[int] to int'hello'[0]stringh :: stringNo matching signature for __getitem__(string, string)true and 0int0 :: intCannot coerce bool to intnull or 7int7 :: intCannot coerce nulltype to int1any1 :: intCannot coerce int to anyThe
Expectedcolumn is what this same library returns for each expression withtarget_typeomitted, so no second implementation is needed to establish it.
The error spans point straight at the mis-targeted operand:
Why these are bugs
Case A. §2.1.7 gives the subscript signatures as
__getitem__(list: list[T], index: int) -> Tand__getitem__(s: string, index: int) -> string.The index is always
intand the receiver is always the collection, irrespective of what thecalling context wants back. A target of
intdescribes the subscript's result; applying itto a
list[int]receiver is a category error, and applying it to the index turns a validintinto a
stringso no signature matches.Case B. §2.1.6 is explicit that
and/orare value-returning: "Ifaisnullorfalse, returna; otherwise evaluate and returnb", and "they return one of theiroperands, not necessarily a
bool". Fortrue and 0the returned operand is0, whichcoerces to
intwithout difficulty. The implementation instead coercestrue— the operandthe rule discards. Same for
null or 7, which returns7but reports a failure to coercenulltype.Case C. §1.2.1 lists
anyas "Unconstrained type (matches anything)", and theExprValuenotes state thatANY,UNIONandUNRESOLVED"are type-level constructs usedduring type checking. They do not appear as the type of a concrete
ExprValueat runtime".An
anytarget is therefore a no-op constraint that every value satisfies; it should never bethe reason an evaluation fails.
Note also §1.3.1: the target "guides implicit type coercion" and "the expression must
produce a value of type
Tor a type that can be implicitly coerced toT" — a statementabout the expression's result, not about its sub-expressions.
Root cause
Cases A and B look like one defect: the set of node kinds that scope the target away from
their operands is enumerated rather than derived.
2e6e1a4addedEvaluator::evaluate_with_targetand applied the unconstrained-operand rule toBinOp,UnaryOp,Compareand thetestslot ofIfExp.SubscriptandBoolOpare not in thatlist, and they are exactly the two shapes that still fail. Any node kind added later inherits
the same hazard.
Case C is separate — it is
ExprValue::coercehaving no arm for ananytarget, so the valuefalls through to the catch-all error. It resembles
429aed0("accept union target_type viamatch-or-coerce"), which fixed the analogous gap for union targets;
anyappears to have beenmissed by the same reasoning. Happy to split it into its own issue if you would rather track it
separately.
Impact
target_typeis the mechanism an embedding context uses to say what it expects, so these firein ordinary use rather than at the edges. §1.3.2 defines the target for a template's
argsitems as
T? | list[T], which means a host evaluating template fields with the documentedtargets hits case A for any expression containing a subscript, and case B for any use of
oras a null-coalescing operator — the pattern §2.1.6's own examples recommend
(
Param.X or "fallback").How this was found
Differential testing against an independent implementation of the same specification: a corpus
of expressions is evaluated by both and the results compared. These are the cases where the two
disagree and the specification supports the other implementation's answer. Happy to supply the
full corpus if it would be useful.