Use this guide when working on TypeScript changes in the FAST monorepo — authoring Web Components, writing templates and styles, working with the observable/reactive system, and testing.
fast-element enables verbatimModuleSyntax, so type-only imports must use import type
or the inline type qualifier:
import type { Notifier, Subscriber } from "./notifier.js";
import { type Constructable, isFunction } from "../interfaces.js";
Sub-entry-points expose focused APIs through the exports map:
import { twoWay } from "@microsoft/fast-element/binding/two-way.js";
import { reactive } from "@microsoft/fast-element/state.js";
import { composedParent } from "@microsoft/fast-element/utilities.js";
The barrel index.ts explicitly lists every re-export grouped by subsystem — no
export * statements.
Elements extend FASTElement. Do not use the @customElement decorator.
Use define() for immediate registration:
export class MyElement extends FASTElement {
@observable items: string[] = [];
}
MyElement.define({
name: "my-element",
template,
styles,
});
Use compose() when registration should be deferred — downstream libraries like Fluent
Web Components use this pattern with a design-system registry:
// my-element.definition.ts
export const definition = MyElement.compose({
name: "my-element",
template,
styles,
});
// define.ts (side-effect import)
definition.define();
Templates use the html tagged template literal typed to the element class:
import { html, repeat, when } from "@microsoft/fast-element";
import type { MyElement } from "./my-element.js";
export const template = html<MyElement>`
<h1>${x => x.title}</h1>
${when(x => x.showList, html<MyElement>`
<ul>
${repeat(
x => x.items,
html<string>`<li>${x => x}</li>`
)}
</ul>
`)}
`;
| Prefix | Purpose | Example |
|---|---|---|
${x => ...} | Content or attribute | ${x => x.name} |
@event | Event listener | @click=${x => x.handleClick()} |
@event | Event with context | @click=${(x, c) => c.parent.remove(x)} |
:prop | DOM property | :value=${twoWay(x => x.description)} |
?attr | Boolean attribute | ?disabled=${x => !x.isValid} |
Two-way bindings require a sub-entry-point import:
import { twoWay } from "@microsoft/fast-element/binding/two-way.js";
Use html.partial() to inject pre-built HTML strings into a template without creating
a full ViewTemplate:
html<MyElement>`
<div>${html.partial("<span>static markup</span>")}</div>
`;
Styles use the css tagged template literal. They attach through the element definition's
styles property:
import { css } from "@microsoft/fast-element";
export const styles = css`
:host {
display: block;
padding: 16px;
}
`;
For declarative HTML definitions, styles live in a separate .styles.css file linked from
both the initial shadow root template and the <f-template>:
<f-template name="my-element">
<template>
<link rel="stylesheet" href="./my-element.styles.css">
</template>
</f-template>
css.partial() works the same way as html.partial() — injecting raw CSS strings.
@attr maps HTML attributes to properties. @observable creates reactive properties
tracked by templates. @volatile marks getters whose dependencies change between calls:
import {
attr,
FASTElement,
nullableNumberConverter,
Observable,
observable,
volatile,
} from "@microsoft/fast-element";
class MyElement extends FASTElement {
@attr label?: string;
@attr({ mode: "boolean" }) active?: boolean;
@attr({ converter: nullableNumberConverter }) count?: number;
@observable private _items: string[] = [];
// Convention: ${propertyName}Changed
labelChanged(prev: string | undefined, next: string | undefined) {}
@volatile
get sortedItems(): readonly string[] {
return [...this._items].sort();
}
}
Notify the system after in-place mutations that it cannot detect automatically:
this._items.splice(index, 1);
Observable.notify(this, "_items");
Make plain objects observable via the state sub-entry-point:
import { reactive } from "@microsoft/fast-element/state.js";
const todo = reactive({ description: "Buy milk", done: false });
Tests use Playwright Test (*.pw.spec.ts) and combine two patterns within the same file.
For logic that does not need browser APIs — the test callback takes no parameters:
import { expect, test } from "@playwright/test";
import { Observable } from "./observable.js";
test.describe("Observable", () => {
test("can get a notifier", () => {
const notifier = Observable.getNotifier(new Model());
expect(notifier).toBeInstanceOf(PropertyChangeNotifier);
});
});
For tests requiring DOM APIs, navigate to the Vite dev server and import via
"/main.js". The @ts-expect-error comment is required because TypeScript cannot
resolve the URL-based import:
test("renders element", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(async () => {
// @ts-expect-error: Client module.
const { FASTElement, html, uniqueElementName } = await import("/main.js");
// ... test logic ...
return someSerializableValue;
});
expect(result).toBe(expected);
});
Only serializable values can cross the page.evaluate boundary — run expect()
assertions outside it on the returned data.
The test harness at packages/<package>/test/main.ts re-exports source modules for
browser tests. When adding new package exports, add them there too.
Frozen const objects paired with a type extracted from their values replace TypeScript
enum:
export const SourceLifetime = {
default: undefined,
couple: 1,
} as const;
export type SourceLifetime =
(typeof SourceLifetime)[keyof typeof SourceLifetime];
FASTElement is both an interface (instance shape) and a const (constructor). Static
methods use typeof to carry overloaded signatures:
export const FASTElement: {
new (): FASTElement;
define: typeof define;
compose: typeof compose;
} = Object.assign(createFASTElement(HTMLElement), { define, compose });
html and css are typed as intersections of a tagged template function and a
.partial() method:
type HTMLTemplateTag = (<TSource, TParent>(
strings: TemplateStringsArray,
...values: TemplateValue<TSource, TParent>[]
) => ViewTemplate<TSource, TParent>) & {
partial(html: string): InlineTemplateDirective;
};