Skip to content

fix and-narrowing (#1169)#1170

Draft
NullVoxPopuli wants to merge 2 commits into
mainfrom
nvp/and-improvements
Draft

fix and-narrowing (#1169)#1170
NullVoxPopuli wants to merge 2 commits into
mainfrom
nvp/and-improvements

Conversation

@NullVoxPopuli

@NullVoxPopuli NullVoxPopuli commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Resolves: #1169

by... potentially lying to the type system -- still investigating

ideally we have the type system match the runtime... since... that's the whole point

? ({
eq: '===',
neq: '!==',
and: '&&',

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

having and / or be in this list makes me nervous, because it's a deviation from runtime / the RFC

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

This reply is from Claude

The nervousness is justified, so here's the precise scope of the deviation — it's narrower and more asymmetric than "and/or now deviate from runtime":

Value position: no deviation. This PR only emits the operator in an if/unless condition. In every other position and/or still emit as the helper invocation, so their return type (FirstFalsy/FirstTruthy) matches the RFC / ember-truth-helpers exactly — empty array falsy, isTruthy honored, (or [] x) types as x. That behavior is unchanged by this PR.

Condition position: a bounded truthiness deviation only. {{#if (and a b)}} becomes if (a && b), which uses JavaScript truthiness — so an empty array reads as truthy, where Ember's toBool would read it as falsy. Two things bound how much this matters:

  1. It's the same approximation glint already makes for every condition. {{#if anArray}} already emits if (anArray) (JS-truthy empty array), not Ember toBool. So and/or in a condition deviate to exactly the degree if/unless already do — this PR doesn't introduce a new kind of inaccuracy, it extends an existing one.
  2. The deviation never co-occurs with the feature it buys. Narrowing only happens when the operands are type-guard booleans, and JS vs. Ember truthiness agree on booleans. The empty-array/proxy divergence only appears with non-boolean operands — i.e. the cases where you weren't narrowing anyway.

Why the operator is unavoidable for the fix. Narrowing is a control-flow property of the emitted TS, and a TS type predicate (x is T) can only narrow a function's own parameter. When and is a helper call, the tested variable lives inside its argument, already collapsed to boolean — so no and signature (generic, callback, assertion) can recover it. Native && in the emitted condition is the only construct TS will narrow through. (Same reason eq/neq are emitted as ===/!== rather than typed helpers.)

So the real question to decide is narrow: is the existing {{#if}} truthiness approximation acceptable to extend to and/or conditions, in exchange for narrowing? If not, the alternative is to keep and/or as helpers everywhere and close #1169 as "use nested {{#if}}s" — there's no third option where a helper call also narrows.

@NullVoxPopuli

Copy link
Copy Markdown
Contributor Author

Note

This reply is from Claude

Some context for reviewers who don't work in glint's internals — what this PR is doing and why it's shaped the way it is.

How glint type-checks a template

glint never runs a template to check it. It transpiles each template into an intermediate TypeScript snippet and hands that to tsc. So every type — and all type narrowing — is a property of the emitted TS, not of any runtime helper. template-to-typescript.ts is that transpiler; the snapshot tests in template-to-typescript.test.ts assert the exact TS it produces.

A "special form" (specialForms in the environment) is a keyword the transpiler emits as bespoke TS instead of a normal helper call. {{#if}} already becomes a real if (...) {}, and eq/neq already become ===/!==. This PR adds and/or&&/|| to that list.

Why and/or must become operators to narrow (#1169)

The bug: {{#if (and (isNumber x.a) (isNumber x.b))}} didn't narrow x.a/x.b inside the block. The reason is fundamental to TypeScript, not a glint shortcoming:

  • TS narrowing is a control-flow property. TS narrows x.a only when it sees isNumber(x.a) as a direct operand in a control-flow position (e.g. && inside an if).
  • A type predicate (x is T) can only narrow a function's own parameter. When and(...) is a helper call, its arguments are the already-collapsed boolean results of isNumber(x.a)x.a is not a parameter of and, so no signature and could declare (generic, callback, assertion, …) can narrow it. The "which variable was tested" information is gone by the time and is called.

So the only way to get narrowing is to emit isNumber(x.a) && isNumber(x.b) natively. That's what the and/or&&/|| mapping does.

The trade-off this PR makes (position-dependent emit)

Native &&/|| use JavaScript truthiness, but Ember's and/or use Ember's toBool (e.g. an empty array is falsy, an Ember proxy honors isTruthy). They also differ in value: (or [] x) returns x at runtime, but [] || x evaluates to [].

To avoid breaking that, the PR emits and/or differently by position:

  • In an if/unless condition → native &&/||, so narrowing works.
  • Everywhere else (value position) → the original helper invocation, so the return type (FirstFalsy/FirstTruthy, which models Ember truthiness) is preserved. This emit is byte-identical to the pre-PR behavior.

The one known approximation to be aware of

In a condition, {{#if (and someArray x)}} now uses JS truthiness, so an empty array is treated as truthy — diverging from Ember's runtime. This is not new: glint already emits {{#if someArray}} as if (someArray), which has the exact same empty-array approximation. The PR just extends that existing approximation to and/or conditions. The runtime-accurate value type is still preserved in value position.

The other behavioral note: arity (and/or require ≥ 2 args) now surfaces as a TypeScript type error from the helper type rather than a transform-time error — see the inline comment on emitLogicalExpression.

eq: '===',
neq: '!==',
and: '&&',
or: '||',

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

This reply is from Claude

This is the line that actually fixes #1169. specialForms maps a template keyword to a piece of TypeScript syntax the transpiler emits instead of a normal helper call — eq/neq were already mapped to ===/!== here. Adding and/or&&/|| is what lets TypeScript's control-flow analysis see isNumber(x.a) && isNumber(x.b) and narrow x.a/x.b inside the block.

It has to be a native operator: a boolean-returning helper can't narrow, because a TS type predicate (x is T) only narrows a function's own parameter — and the variable being tested lives inside the helper's argument, not as a parameter of and itself.

);

if (!inTestPosition || node.params.length < 2) {
// Outside a condition we emit the keyword as a helper invocation

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

This reply is from Claude

This branch is the heart of the design, and the subtlety worth understanding:

  • !inTestPosition → emit the helper. Native &&/|| use JavaScript truthiness, but Ember's and/or use toBool (empty array is falsy, proxies honor isTruthy), and they also return a value, not a boolean. Emitting the helper here keeps the runtime-accurate return type (FirstFalsy/FirstTruthy). Only an if/unless condition sets inTestPosition = true (see the emitExpression(node.params[0], true) calls), so in every other position the emit is identical to the pre-PR helper behavior.

  • params.length < 2 → also emit the helper. and/or require at least two operands. A one-armed &&/|| would be a syntax error, so we fall back to the helper, whose type already requires ≥ 2 args. This is a deliberate behavior change: the "too few arguments" error now comes from the helper's type (an ordinary TS error, suppressible with @glint-expect-error) instead of a transform-time error. That's why the two "wrong number of parameters" transform tests were updated to expect no transform error.

Net effect: narrowing in conditions, runtime-accurate types everywhere else. The cost is that an empty array is treated as truthy in a condition — the same approximation glint already makes for plain {{#if anArray}}.

for (const [index, param] of node.params.entries()) {
emitExpression(param);
if (index === 0 && formInfo.requiresConsumption) {
// Ride the keyword consumption on the first operand

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

This reply is from Claude

A glint detail that makes this look stranger than it is. For a documented keyword, glint emits a throwaway reference to it (__glintDSL__.noop(Globals.and)) so that editor hover / go-to-definition still work on and even though the runtime form is just an operator.

The non-obvious part is where that reference goes. Wrapping the whole chain as (noop(and), (a && b)) would put the operator chain on the right of a comma — and TypeScript will not narrow an &&-chain of type guards through a comma expression (it does narrow a single guard or an ===, which is why eq/not don't need this dance). So the consumption rides on just the first operand(noop(and), a) && b — keeping the && chain at the top level where control-flow analysis still narrows it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Glint 2] and doesn't type-narrow correctly

1 participant