Dev.to AI πŸ€– Ai πŸ‘ 0 πŸ“– 6 min read

tsc looked at my templates and saw a nice string.

Sixth in a series on using @relax.js/core with a coding agent. This is the piece I said in the first article bothered me enough to fix. The gap When I worked in Angular, a wrong property in a template was a b

Sixth in a series on using @relax.js/core with a coding agent. This is the piece I said in the first article bothered me enough to fix.

The gap

When I worked in Angular, a wrong property in a template was a build error. vue-tsc does the same for Vue. Relaxjs sells itself on having no compiler, and the price was that a template is a string: {{user.naem}} is perfectly valid TypeScript, because it is text.

The previous two articles are about catching it at runtime, in a test, with the errors captured. That works, and it depends on three things the agent has to remember: write the test, render with a representative model, assert on the captured errors. Forget one and the typo ships.

The rule I keep coming back to, from the design notes I write for agents: an API that makes wrong usage a type error is worth far more than one that makes it a runtime warning, because nobody is watching the console. So the question was whether a library with no build step could have a type check for its templates anyway.

Give the call its types

compileTemplate takes two type parameters: the view model render() receives, and the functions context it receives as its second argument.

interface ViewModel {
    heading: string;
}

interface Handlers {
    discard: () => void;
}

.. and using it like:

this.template = compileTemplate<ViewModel, Handlers>(`
    <form class="form">
        <h1>{{heading}}</h1>

        <label for="displayName">Display name</label>
        <input id="displayName" name="displayName" required>

        <label for="email">Email</label>
        <input id="email" name="email" type="email" required>

        <label for="bio">Bio</label>
        <textarea id="bio" name="bio"></textarea>

        <button type="submit">Save</button>
        <button type="button" r-click="discard()">Discard changes</button>
    </form>
`);

Nothing changes at runtime. The types are there for the checker.

Run it

npx @relax.js/core check

It reads the project's tsconfig.json, finds every compileTemplate call whose template is a literal and every html`…` literal, and resolves each expression against the types with the TypeScript compiler API. Here is the example app's page as an agent might first write it, with two typos and one habit carried over from the other engine:

export const page = compileTemplate<ViewModel, Handlers>(`
    <form class="form">
        <h1>{{heading}}</h1>
        <p>Signed in as {{profile.emial}}</p>
        <button type="button" r-clik="discard()">Discard changes</button>
    </form>
`);

const card = html`<p class="status">{{user.name}}</p>`;
export const status = card({ user: { name: 'Alice' } });

And the output, verbatim:

src/pages/ProfilePage.broken.ts:21:27 - error: Cannot resolve "profile.emial": Profile has no property "emial"
src/pages/ProfilePage.broken.ts:22:39 - error: "r-clik" is not a known event for <button>
src/pages/ProfilePage.broken.ts:26:39 - error: "user.name" is not a property name; html templates take flat names

2 templates checked

Exit code 1. The format is tsc's, one line per expression, column pointing at the expression rather than at the call. Editors and agents already know how to read it.

The third line is the one I like most. The html tag takes flat names; {{user.name}} is looked up as one key, so it is a mistake even though the object has a user. The checker knows the context type without an annotation: it takes it from the bind call, card({ user: ... }), either the immediate call or the first call in the same file of the variable or class field the template was stored in.

What is checked

  • Every {{path}}, if, unless and handler argument: each segment is a property of the type before it. null and undefined are looked through because the runtime renders them as empty. [0] on an array gives the element type. any ends the walk.
  • loop="row in rows": rows is an array, and row has its element type inside the element and goes out of scope when the element closes.
  • {{fn(a, 'x', 1)}} and r-click="fn(row, event)": fn is a property of the functions type with a call signature that accepts the argument count and types. event is Event, in handlers only.
  • r-<event>: on<event> exists on the element's DOM type, the same test the runtime makes.
  • Pipes: one of the built-ins, unless the call passes its own registry.
  • {{ is closed on the same line.

What is skipped, and said so

A template held in a variable, built with ${} substitutions or loaded from a file has no text at the call site. A compileTemplate call without type arguments still gets the syntax, event and pipe checks, but no path resolution. An html bind function that is returned or called in another file has no context type. Each of these is counted in the summary with its reason:

12 templates checked, 2 skipped (1 no type argument, 1 not a literal)

The templates skill puts it as: silence with skipped templates is not green.

Why it will not disagree with the runtime

The obvious risk with a checker is that it drifts from the thing it checks. Two decisions keep that small. The expression grammar ({{expr}}, pipes, loop, function calls) is one module shared by the runtime and the checker, so they cannot parse an expression differently. And the event test is the runtime's test: on<event> on the element type.

What is not shared is the HTML structure. The runtime uses DOMParser; the checker reads the template with a small scanner, because it runs under Node without a DOM. The scanner does not apply the browser's content-model rules, so a <p> closed early by a block element or a <tr> outside a <table> could scope a loop alias differently from how it renders. Keep templates well-formed and the two agree. The doc says this, because a caveat the agent cannot read is a caveat that does not exist.

Make the test suite run it

A command only helps when it is run. The agent runs npm test after every change; it does not necessarily run check. So the example app has this test, and the skill tells the agent to add it if a project lacks one:

const tsconfig = path.resolve(__dirname, '../tsconfig.json');

describe('templates', () => {
    it('every_template_resolves_against_its_view_model', () => {
        const result = checkProject(tsconfig);
        expect(result.diagnostics.map(formatDiagnostic)).toEqual([]);
        expect(result.skipped).toEqual([]);
    });
});

The second assertion is the strict one: no template in this project gets less than a full check. Drop it if you have a legitimately dynamic template somewhere.

typescript is an optional peer dependency. Nothing in the runtime loads it; only check does.

What this gives up and gets back

Angular checks templates inside the compiler, with editor integration and an error on the exact character in the template. This gets the exact character and the tsc format, from a separate command, for templates that are literals with types on the call. It does not get editor squiggles, and it does not see a template you load from a file.

For an agent that is most of what matters. The failure is now a line in the output of a command it already runs, before anything renders, and the message names the type and the property. Next: a full session, prompt to passing tests.

πŸ“° Read the original article on Dev.to AI

Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.