Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,18 @@ export default function emberTemplateImportsEnvironment(
// discriminated unions — e.g. `{{#if (eq foo.kind "a")}}` narrows
// `foo` to the matching variant inside the block, which a
// boolean-returning helper type cannot do.
//
// The same reasoning applies to `and`/`or`: emitting them as the
// native `&&`/`||` operators lets `{{#if (and (isNumber x) ...)}}`
// propagate the type predicates of its operands into the block, which
// a value-returning helper type cannot do (typed-ember/glint#1169).
...(hasEmber71BuiltIns()
? ({ eq: '===', neq: '!==' } satisfies GlintSpecialFormConfig['globals'])
? ({
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.

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.

} satisfies GlintSpecialFormConfig['globals'])
: {}),
...additionalGlobalSpecialForms,
},
Expand Down
72 changes: 56 additions & 16 deletions packages/ember-tsc/src/transform/template/template-to-typescript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,8 +242,16 @@ export function templateToTypescript(
formInfo: SpecialFormInfo,
node: AST.MustacheStatement | AST.SubExpression,
position: InvokePosition,
inTestPosition = false,
): void {
if (formInfo.requiresConsumption) {
// Logical forms (`&&`/`||`) manage their own keyword consumption: wrapping
// the whole operator chain in a `(noop(kw), <chain>)` comma defeats
// TypeScript's control-flow narrowing of an `&&`/`||` chain of type
// guards. Instead they ride the consumption on the first operand only
// (see `emitLogicalExpression`), keeping the chain at the top level.
const isLogicalForm = formInfo.form === '&&' || formInfo.form === '||';

if (formInfo.requiresConsumption && !isLogicalForm) {
mapper.text('(__glintDSL__.noop(');
emitExpression(node.path);
mapper.text('), ');
Expand Down Expand Up @@ -281,7 +289,7 @@ export function templateToTypescript(

case '&&':
case '||':
emitLogicalExpression(formInfo, node);
emitLogicalExpression(formInfo, node, inTestPosition);
break;

case '!':
Expand All @@ -293,7 +301,7 @@ export function templateToTypescript(
mapper.text('undefined');
}

if (formInfo.requiresConsumption) {
if (formInfo.requiresConsumption && !isLogicalForm) {
mapper.text(')');
}
}
Expand Down Expand Up @@ -424,7 +432,7 @@ export function templateToTypescript(
);

mapper.text('(');
emitExpression(node.params[0]);
emitExpression(node.params[0], true);
mapper.text(') ? (');
emitExpression(node.params[1]);
mapper.text(') : (');
Expand All @@ -450,7 +458,7 @@ export function templateToTypescript(
);

mapper.text('!(');
emitExpression(node.params[0]);
emitExpression(node.params[0], true);
mapper.text(') ? (');
emitExpression(node.params[1]);
mapper.text(') : (');
Expand Down Expand Up @@ -492,20 +500,47 @@ export function templateToTypescript(
function emitLogicalExpression(
formInfo: SpecialFormInfo,
node: AST.MustacheStatement | AST.SubExpression,
inTestPosition: boolean,
): void {
mapper.forNode(node, () => {
assert(
node.hash.pairs.length === 0,
() => `{{${formInfo.name}}} only accepts positional parameters`,
);
assert(
node.params.length >= 2,
() => `{{${formInfo.name}}} requires at least two parameters`,
);

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}}.

// rather than a native operator chain. The helper's return type
// (`FirstFalsy`/`FirstTruthy`) reproduces Ember's runtime truthiness —
// e.g. empty arrays and `{ isTruthy: false }` are falsy — which the
// JavaScript `&&`/`||` operators do not. The helper also enforces the
// "at least two operands" requirement as an ordinary type error, so it
// handles the too-few-operands case too. (This matches what the
// keyword would emit if it were a plain helper rather than a special
// form, so value-position behavior is unchanged.)
emitResolve(node, 'resolve');
return;
}

// In a condition, emit the native `&&`/`||` operator chain so that
// TypeScript's control-flow analysis narrows the operands' type guards
// inside the block (e.g. `{{#if (and (isNumber x) (isNumber y))}}`).
mapper.text('(');
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.

// (`(noop(kw), <first>) && <rest>`) so the operator chain stays at
// the top level of the expression. Wrapping the whole chain in the
// comma instead would discard TypeScript's narrowing of an `&&`/`||`
// chain of type guards.
mapper.text('(__glintDSL__.noop(');
emitExpression(node.path);
mapper.text('), ');
emitExpression(param);
mapper.text(')');
} else {
emitExpression(param);
}

if (index < node.params.length - 1) {
mapper.text(` ${formInfo.form} `);
Expand Down Expand Up @@ -570,13 +605,18 @@ export function templateToTypescript(
return null;
}

function emitExpression(node: AST.Expression): void {
// `inTestPosition` marks expressions emitted as the condition of an
// `if`/`unless`. It only affects `and`/`or`: in a condition they emit as
// native `&&`/`||` so TypeScript narrows their operands, whereas in any
// other (value) position they emit as helper invocations to preserve their
// exact runtime return type (see `emitLogicalExpression`).
function emitExpression(node: AST.Expression, inTestPosition = false): void {
switch (node.type) {
case 'PathExpression':
return emitPath(node);

case 'SubExpression':
return emitSubExpression(node);
return emitSubExpression(node, inTestPosition);

case 'BooleanLiteral':
case 'NullLiteral':
Expand Down Expand Up @@ -1157,7 +1197,7 @@ export function templateToTypescript(
);

mapper.text('if (');
emitExpression(node.params[0]);
emitExpression(node.params[0], true);
mapper.text(') {');
mapper.newline();
mapper.indent();
Expand Down Expand Up @@ -1191,7 +1231,7 @@ export function templateToTypescript(
);

mapper.text('if (!(');
emitExpression(node.params[0]);
emitExpression(node.params[0], true);
mapper.text(')) {');
mapper.newline();
mapper.indent();
Expand Down Expand Up @@ -1309,10 +1349,10 @@ export function templateToTypescript(
scope.pop();
}

function emitSubExpression(node: AST.SubExpression): void {
function emitSubExpression(node: AST.SubExpression, inTestPosition = false): void {
let specialFormInfo = checkSpecialForm(node);
if (specialFormInfo) {
emitSpecialFormExpression(specialFormInfo, node, 'sexpr');
emitSpecialFormExpression(specialFormInfo, node, 'sexpr', inTestPosition);
return;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -499,55 +499,91 @@ describe('Transform: rewriteTemplate', () => {
});

describe('{{and}}', () => {
test('with two arguments', () => {
// In value position `and` emits as a helper invocation so its return type
// reproduces Ember's runtime truthiness (see truth-convert.d.ts). The
// native `&&` operator is only used in a condition (below), where its
// narrowing matters and a truthiness approximation is already in play.
test('in value position emits a helper invocation', () => {
let template = stripIndent`
{{log (testAnd 1 2)}}
`;

expect(
templateBody(template, { globals: ['testAnd'], specialForms: { testAnd: '&&' } }),
).toMatchInlineSnapshot(
`"__glintDSL__.emitContent(__glintDSL__.resolve(log)((__glintDSL__.noop(__glintDSL__.Globals.testAnd), (1 && 2))));"`,
`"__glintDSL__.emitContent(__glintDSL__.resolve(log)(__glintDSL__.resolve(__glintDSL__.Globals.testAnd)(1, 2)));"`,
);
});

test('with three arguments', () => {
test('with three arguments in value position emits a helper invocation', () => {
let template = stripIndent`
{{log (testAnd 1 2 3)}}
`;

expect(
templateBody(template, { globals: ['testAnd'], specialForms: { testAnd: '&&' } }),
).toMatchInlineSnapshot(
`"__glintDSL__.emitContent(__glintDSL__.resolve(log)((__glintDSL__.noop(__glintDSL__.Globals.testAnd), (1 && 2 && 3))));"`,
`"__glintDSL__.emitContent(__glintDSL__.resolve(log)(__glintDSL__.resolve(__glintDSL__.Globals.testAnd)(1, 2, 3)));"`,
);
});

test('in a condition emits a native operator chain (so TS narrows operands)', () => {
let template = stripIndent`
{{#testIf (testAnd foo bar)}}{{/testIf}}
`;

expect(
templateBody(template, {
globals: ['testIf', 'testAnd'],
specialForms: { testIf: 'if', testAnd: '&&' },
}),
).toMatchInlineSnapshot(`
"if (((__glintDSL__.noop(__glintDSL__.Globals.testAnd), foo) && bar)) {
}"
`);
});
});

describe('{{or}}', () => {
test('with two arguments', () => {
test('in value position emits a helper invocation', () => {
let template = stripIndent`
{{log (testOr 1 2)}}
`;

expect(
templateBody(template, { globals: ['testOr'], specialForms: { testOr: '||' } }),
).toMatchInlineSnapshot(
`"__glintDSL__.emitContent(__glintDSL__.resolve(log)((__glintDSL__.noop(__glintDSL__.Globals.testOr), (1 || 2))));"`,
`"__glintDSL__.emitContent(__glintDSL__.resolve(log)(__glintDSL__.resolve(__glintDSL__.Globals.testOr)(1, 2)));"`,
);
});

test('with three arguments', () => {
test('with three arguments in value position emits a helper invocation', () => {
let template = stripIndent`
{{log (testOr 1 2 3)}}
`;

expect(
templateBody(template, { globals: ['testOr'], specialForms: { testOr: '||' } }),
).toMatchInlineSnapshot(
`"__glintDSL__.emitContent(__glintDSL__.resolve(log)((__glintDSL__.noop(__glintDSL__.Globals.testOr), (1 || 2 || 3))));"`,
`"__glintDSL__.emitContent(__glintDSL__.resolve(log)(__glintDSL__.resolve(__glintDSL__.Globals.testOr)(1, 2, 3)));"`,
);
});

test('in a condition emits a native operator chain (so TS narrows operands)', () => {
let template = stripIndent`
{{#testIf (testOr foo bar baz)}}{{/testIf}}
`;

expect(
templateBody(template, {
globals: ['testIf', 'testOr'],
specialForms: { testIf: 'if', testOr: '||' },
}),
).toMatchInlineSnapshot(`
"if (((__glintDSL__.noop(__glintDSL__.Globals.testOr), foo) || bar || baz)) {
}"
`);
});
});

describe('{{not}}', () => {
Expand Down Expand Up @@ -1361,18 +1397,16 @@ describe('Transform: rewriteTemplate', () => {
]);
});

test('{{and}} with wrong number of parameters', () => {
test('{{and}} with too few parameters falls back to a helper invocation', () => {
// Fewer than two operands can't form an `&&` chain, so we emit the keyword
// as a helper invocation instead. This produces no transform error; the
// keyword's own type (e.g. `AndHelper`) reports the arity requirement.
let { errors } = templateToTypescript('<Foo @attr={{testAnd 123}} />', {
typesModule: '@glint/template',
specialForms: { testAnd: '&&' },
});

expect(errors).toEqual([
{
message: '{{testAnd}} requires at least two parameters',
location: { start: 11, end: 26 },
},
]);
expect(errors).toEqual([]);
});

test('{{or}} with named parameters', () => {
Expand All @@ -1389,18 +1423,16 @@ describe('Transform: rewriteTemplate', () => {
]);
});

test('{{or}} with wrong number of parameters', () => {
test('{{or}} with too few parameters falls back to a helper invocation', () => {
// Fewer than two operands can't form an `||` chain, so we emit the keyword
// as a helper invocation instead. This produces no transform error; the
// keyword's own type (e.g. `OrHelper`) reports the arity requirement.
let { errors } = templateToTypescript('<Foo @attr={{testOr 123}} />', {
typesModule: '@glint/template',
specialForms: { testOr: '||' },
});

expect(errors).toEqual([
{
message: '{{testOr}} requires at least two parameters',
location: { start: 11, end: 25 },
},
]);
expect(errors).toEqual([]);
});

test('{{not}} with named parameters', () => {
Expand Down
49 changes: 49 additions & 0 deletions test-packages/ts-gts-7-1-app/src/globals/narrowing.test.gts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,30 @@ const foo = { x: 'a', y: 1 } as Foo;
const fooA = { x: 'a', y: 1 } as { x: 'a'; y: number };
const fooB = { x: 'b', y: 's' } as { x: 'b'; y: string };

// ---------------------------------------------------------------------------
// (5) Control-flow narrowing via `and`/`or` (typed-ember/glint#1169). These are
// emitted as the native `&&`/`||` operators, so a chain of type-predicate
// guards propagates each `arg is T` predicate into the block — something a
// value-returning helper type cannot do.
// ---------------------------------------------------------------------------
function isNumber(value: unknown): value is number {
return typeof value === 'number';
}
type Mixed = { a: unknown; b: unknown };
const mixed = { a: 1, b: 2 } as Mixed;

// ---------------------------------------------------------------------------
// (6) Value-position `and`/`or` must keep Ember's runtime truthiness. Although
// they emit as native `&&`/`||` inside a condition (section 5), in value
// position they emit as helper invocations whose return type follows Ember's
// `isTruthy`/`ember-truth-helpers` rules — under which an empty array and a
// `{ isTruthy: false }` object are *falsy*, even though JavaScript's `&&`/`||`
// treat both as truthy. These assertions would fail if value position used the
// native operators. (typed-ember/glint#1169)
// ---------------------------------------------------------------------------
const emptyArray = [] as never[];
const isFalsyBox = { isTruthy: false, tag: 'empty' } as { isTruthy: false; tag: 'empty' };

<template>
{{! ===== (1) result-type narrowing of or / and ===== }}

Expand Down Expand Up @@ -142,4 +166,29 @@ const fooB = { x: 'b', y: 's' } as { x: 'b'; y: string };
{{else}}
{{expectTypeOf foo.y to.beNumber}}
{{/if}}

{{! ===== (5) narrowing via the `and`/`or` keywords (glint#1169) ===== }}

{{! ---- a single type-predicate guard narrows its argument ---- }}
{{#if (isNumber mixed.a)}}
{{expectTypeOf mixed.a to.beNumber}}
{{/if}}

{{! ---- `(and ...)` propagates each operand's type predicate, so both
operands narrow inside the block ---- }}
{{#if (and (isNumber mixed.a) (isNumber mixed.b))}}
{{expectTypeOf mixed.a to.beNumber}}
{{expectTypeOf mixed.b to.beNumber}}
{{/if}}

{{! ===== (6) value-position results keep Ember truthiness (glint#1169) ===== }}

{{! ---- an empty array is Ember-falsy, so `(or emptyArray fallback)` resolves
to the string fallback. Native `||` would type this as `never[] | string`. ---- }}
{{expectTypeOf (or emptyArray fallback) to.beString}}

{{! ---- a `{ isTruthy: false }` object is Ember-falsy, so `(and ...)` returns
it as the first falsy operand. Native `&&` would type this as the trailing
operand instead. ---- }}
{{expectTypeOf (and isFalsyBox fallback) to.equalTypeOf isFalsyBox}}
</template>
Loading