This is a great questionβthe answer is no, but understanding why will help people build a more robust mental model for blocks in general!
I'll give an example here as though components were functions whose blocks are functions they accept, along with the arguments. This is an inaccurate but useful mental model; the real thing is actually modeled basically on Ruby's yield
; I am going to leave that aside because it is not that important!
Here's an idea of a simple function-like component which has the right pieces to give us a mental model here:
interface MyArgs {
name: string;
age: number;
}
interface MyBlocks {
birthday?: (age: number) => string;
}
function BasicBlockUsage(args: MyArgs, blocks: MyBlocks = {}): string {
return `
<p>Hello, ${args.name}!</p>
${
blocks.birthday
? `<p class='birthday'>${blocks.birthday(args.name)}</p>`
: ''
}
`
}
(You could also model this as blocks being return types, rather than an additional argument, but the semantics are identical in the end and the invocation syntax for that is much worse!)
Roughly equivalent Glimmer component definition:
interface BasisBlockSig {
Args: {
name: string;
age: number;
};
Blocks: {
birthday: [age: number];
};
}
const BasicBlockUsage: TOC<BasisBlockSig> = <template>
{{#if (has-block 'birthday')}}
<p class="birthday">
{{yield @age to='birthday'}}
</p>
{{/if}}
</template>;
The key thing to notice here is that the caller is in control of two things about the blocks:
-
Whether they are passed in at all. This actually represents the semantics of a Glimmer component correctly (though we model it somewhat differently, using the Signature
types, for reasons having to do with what we have to do with the types to apply them correctly to our translation of Glimmer into TS, including making sure you get decent error messages when something is wrong!). Any component can be invoked without invoking its blocks; they are always optional.
-
What they return. A block (named or default) is yielding all control to the caller, and any arbitrary DOM content is allowed to go in that spot. This maps exactly to the semantics of HTML (and, notably, is not a Glimmer-specific issue: React children
and Vue and Svelte slots
have the exact same dynamic). From our POV here, this is the reason that the birthday
block just returns string
: you can put anything which we can "render" (console.log
) here.
Net, you can pass in no block at all, or pass in a block which does anything:
let withoutBlock = BasicBlockUsage({ name: "Chris", age: 35 });
console.log(withoutBlock);
/*
<p>Hello, Chris!</p>
*/
let withBlock = BasicBlockUsage({ name: "Chris", age: 35 }, {
birthday: (age) => `Happy ${age}th birthday!`
});
console.log(withBlock);
/*
<p>Hello, Chris!</p>
<p class='birthday'>Happy 35th birthday!</p>
*/
let withBlockBananas = BasicBlockUsage({ name: "Chris", age: 35 }, {
birthday: (_) => `ππππππππππ`;
});
console.log(withBlock);
/*
<p>Hello, Chris!</p>
<p class='birthday'>ππππππππππ</p>
*/
Comparable Glimmer invocations
const WithoutBlock = <template>
<BasicBlockUsage @name="Chris" @age={{35}} />
</template>;
const WithBlock = <template>
<BasicBlockUsage @name="Chris" @age={{35}}>
<:birthday as |age|>
Happy {{age}}th birthday!
</:birthday>
</BasicBlockusage>
</template>;
Now, this is not a TS limitation per se: in other contexts we could require an object like { buttons: () => Array<Button> }
. But Glimmer (like other rendering engines) doesn't have that capability, so our types cannot either. That is why Blocks
are typed as just the list of values yielded to the block, rather than capturing the return type (which you couldn't write anything useful for anyway). And critically, they model what the component offers to the user (i.e. what blocks it will do anything with), rather than what the caller must doβbecause neither JS nor Glimmer has any notion of "must use" requirements at a language level.
Notice that you cannot even keep someone invoking a given component with a non-defined block (much as you cannot in general stop someone from passing in an object with extra properties in TS).