Skip to content

Instantly share code, notes, and snippets.

@chriskrycho
Last active May 5, 2023 14:53
Show Gist options
  • Save chriskrycho/b6210c79c6d11939187054772694e2f4 to your computer and use it in GitHub Desktop.
Save chriskrycho/b6210c79c6d11939187054772694e2f4 to your computer and use it in GitHub Desktop.
Ember block content enforcement (relevant to Vue/Svelte slots and React children, too!)

I was wondering if there's currently any way in TS to enforce the child types passed into a yield block? I looked into the component signature and it seems like Blocks specifies the things that are yielded out but doesn't impose any restrictions on what's passed in.

For example, if I have some component Foo:

<div class="foo">
  {{yield}}
</div>

But I want Foo to only wrap HTML button elements, is that enforceable via TS? If not, are there other practices we can employ to enforce this?

e.g. this is valid:

<Foo>
  <button>1</button>
  <button>2</button>
</Foo>

but this is invalid:

<Foo>
  <div>not a button</div>
  whatever I want
</Foo>

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:

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

  2. 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).

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