| name | idiomatic-ts-lit |
| description | A living reference for writing idiomatic TypeScript and Lit in the Breadboard Visual Editor. Covers type safety, import hygiene, component design, and the patterns that distinguish a great frontender from a merely competent one. |
💎 Idiomatic TypeScript + Lit
A living reference for what "great" looks like in this codebase. Each section
names a pattern (or anti-pattern) and shows exactly what to write instead. Think
of this as the bar for code review — the things that make you smile or wince
when you see them.
1. Type Safety — Say What You Mean
1.1 No Unsafe Casts — "The Unseen Cast"
Our codemod rewrote 26 event casts in a single session. The pattern was hiding
real bugs — when trigger wiring changed, .detail silently returned
undefined.
async (evt?: Event): Promise<void> => {
const detail = (evt as StateEvent<"node.change">).detail;
};
async (evt?: StateEvent<"node.change">): Promise<void> => {
const detail = evt!.detail;
};
Principle: A cast says "I know better than the compiler." That's almost
always a lie. Move the type to the signature.
1.2 as any Is a Fire Alarm
as any in production code means the type system has a gap. Four remaining
instances in our src/ are all accounted for — if you need another, explain why
in a comment. But be prepared for pushback from the user.
const result = someValue as any;
if (isValidResult(result)) {
const typed: SpecificType = result;
}
Acceptable escapes:
- Test mocks —
as unknown as AppServices with a partial mock is fine;
that's the point of a mock.
- Library boundaries — When a third-party type is wrong, cast with a
// TODO(types): comment linking the issue.
- Trust boundaries — Trusted types (CSP) require casts by design.
1.3 as unknown as T — The Double Cast
25 occurrences in our source. Some are legitimate (decorators, trusted types,
signal-backed collection wrappers). Some are hiding a missing generic. Before
writing one, ask:
Can I add a type parameter, use a type guard, or restructure the API to avoid
this?
const transformer = raw as unknown as DataPartTransformer;
const transformer = createTransformer<DataPartTransformer>(raw);
1.4 Discriminated Unions Over Type Assertions
When you have a value that could be one of several shapes, use discriminated
unions with exhaustive checks.
if ((result as ErrorResult).$error) { ... }
type Outcome<T> = { value: T } | { $error: string };
function handle(outcome: Outcome<Data>) {
if ("$error" in outcome) {
return err(outcome.$error);
}
return ok(outcome.value);
}
2. Import Hygiene — Dependencies at a Glance
2.1 Static import type Over Inline import()
We codified this as "Standard 5.30" in our linting patterns. Inline imports
clutter signatures and hide dependencies.
export function mapState(
state: import("@breadboard-ai/types").NodeLifecycleState
): import("@breadboard-ai/types").NodeRunStatus { ... }
import type { NodeLifecycleState, NodeRunStatus } from "@breadboard-ai/types";
export function mapState(state: NodeLifecycleState): NodeRunStatus { ... }
2.2 Dynamic import() — Only for Lazy Loading
Dynamic imports are for code splitting and lazy loading. If the module is always
used, it should be a static import.
const { html } = await import("lit");
const pdfLib = await import("pdfjs-dist/build/pdf.mjs");
Current legitimate uses: bootstrap entry points, polyfills, PDF viewer, debug
tools. If your dynamic import doesn't fall into "user may never trigger this
code path," make it static.
2.3 Explicit Exports at the Top
Canonical pattern in this repo: imports first, then explicit exports, then
implementations. This makes the public API scannable without scrolling.
import { foo } from "./foo.js";
export { bar, baz };
function bar() {
}
function baz() {
}
3. Lit Components — Thin Rendering Shells
3.1 Components Are Not Controllers
If your component class has a Map, a Set, or any business logic beyond DOM
intrinsic state (scroll position, focus, animation frames), it's doing too much.
@customElement("my-list")
class MyList extends LitElement {
private items = new Map<string, Item>();
private async loadItems() { ... }
}
@customElement("my-list")
class MyList extends SignalWatcher(LitElement) {
@consume({ context: scaContext })
accessor sca!: SCA;
render() {
const items = this.sca.controller.editor.items;
return html`${items.map(i => html`<my-item .data=${i}></my-item>`)}`;
}
}
3.2 Definite Assignment for Context
Context is guaranteed by the application shell. Optional chaining on this.sca
is noise — it hides real bootstrap errors behind silent undefined.
@consume({ context: scaContext })
protected accessor sca: SCA | undefined = undefined;
@consume({ context: scaContext })
protected accessor sca!: SCA;
3.3 SignalWatcher Is Non-Negotiable
If a component reads from SCA controllers, it must extend
SignalWatcher(LitElement). Without it, signal reads don't trigger re-renders.
The one exception: pure "service locator" consumers that only call imperative
methods (not read signals) in event handlers. Document this with an eslint
disable comment.
3.4 No @consume Reads in Constructors or Initializers
Context values aren't injected until connectedCallback. Accessing them during
class setup phase gives you undefined.
@customElement("my-thing")
class MyThing extends SignalWatcher(LitElement) {
@consume({ context: scaContext })
accessor sca!: SCA;
private tools = this.sca.controller.tools;
}
@customElement("my-thing")
class MyThing extends SignalWatcher(LitElement) {
@consume({ context: scaContext })
accessor sca!: SCA;
render() {
const tools = this.sca.controller.tools;
return html`...`;
}
}
4. Signal Patterns — Reactivity with Precision
4.1 @field for All Controller State
No SignalMap, SignalSet, SignalArray, or @signal from
@lit-labs/signals in SCA controllers. They bypass hydration and persistence.
private _myMap = new SignalMap<string, string>();
@field({ deep: true })
private accessor _myMap: Map<string, string> = new Map();
4.2 void for Intentional Signal Reads
When you read a signal purely for subscription (side effect), the linter flags
it as an unused expression. Use void to communicate intent.
controller.editor.graph.version;
void controller.editor.graph.version;
4.3 Version + 1 for Triggers
A trigger condition that returns 0 is falsy — the trigger never fires. Always
offset by 1.
return controller.editor.graph.version;
return controller.editor.graph.version + 1;
5. Error Handling — Outcomes, Not Exceptions
5.1 Return Outcomes, Don't Throw
Actions return Outcome<T> — either ok(value) or err("message"). This makes
error paths explicit and testable.
throw new Error("No editor available");
if (!editor) return err("No editor available");
5.2 Fail-Early Guards
The first lines of an Action should validate preconditions. Don't nest deep
before discovering the state is invalid.
const { controller, services } = bind;
const editor = controller.editor.graph.editor;
if (!editor) return err("No editor available");
if (!metadata) return err("Missing metadata");
6. Testing — No Coverage Theater
6.1 mock.method — Never Direct Overwrites
globalThis.fetch = () => Promise.resolve(new Response("{}"));
mock.method(globalThis, "fetch", () => Promise.resolve(new Response("{}")));
6.2 as unknown as AppServices for Partial Mocks
This is the one place double-casting is encouraged. Partial mocks should only
implement what the test exercises.
Asset.bind({
controller,
services: { googleDriveBoardServer: { dataPartTransformer: () => mock } }
as unknown as AppServices,
});
6.3 Verify Assertions Actually Ran
A test that passes because a guard skipped the logic provides zero coverage. Add
a console.log or explicit counter during development to confirm the assertion
block executed.
7. Naming — Make the Code Read Like Prose
7.1 Name Your Bugs
Named bugs are memorable bugs. "The Unseen Cast" is instantly recognizable.
"Fixed event handler typing" is forgettable.
| Bug Name | What it catches |
|---|
| The Unseen Cast | Unsafe (evt as T).detail in event handlers |
| The Silent Stacking | Kahn's algorithm silently drops cyclic nodes |
| The Sticky Trigger | Trigger condition returns 0 (falsy), never fires |
| The Boolean Trigger | Presence-based trigger that doesn't reset after consuming |
7.2 Actions Are Verbs, Controllers Are Nouns
board.save() ← Action: what the user does
editor.graph ← Controller: what the system holds
graphStore.fetch() ← Service: infrastructure verb
8. Architectural Discipline
8.1 No Cross-Action Imports
Actions talk to each other through Triggers and Pending Signals, never
through direct imports. This prevents circular dependencies and enforces the
"Action independence" invariant.
8.2 Destructure bind at the Top
bind.controller.editor.graph.setEditor(e);
const { controller, services } = bind;
controller.editor.graph.setEditor(e);
8.3 Shared Utilities Before New Code
Before writing a helper, check packages/utils and src/utils/. The Déjà Code
system will flag you if you reimplement something that already exists — but
catching it before you write it is better.
9. Lit Patterns — The Framework-Specific Craft
The fundamental contract: State lives in SCA. Rendering lives in
components. That's not a suggestion — it's the architectural boundary that
makes everything else work.
A component reads from Controllers (via signals) and dispatches
through Actions. It never owns business state, never fetches data, never
decides what happens next. Those are Controller, Service, and Action
concerns respectively. The component's only job is to turn signals into pixels
and user gestures into action calls.
┌──────────────────────────────────┐
│ UI Component │
│ reads signals → renders HTML │
│ handles events → calls Actions │
│ owns ONLY: scroll, focus, anim │
└──────────┬───────────────────────┘
│
┌──────────▼───────────────────────┐
│ SCA (Controllers/Actions) │
│ owns ALL business state │
│ orchestrates ALL workflows │
│ coordinates ALL services │
└──────────────────────────────────┘
If you find yourself adding a Map, a fetch(), or an if/else business rule
inside a component — stop. That logic has a home, and it's not here.
9.1 Templates: nothing Over Empty Strings
Lit's nothing sentinel removes nodes from the DOM entirely. An empty string
leaves an empty text node — a subtle difference that matters for CSS selectors,
childNodes.length, and layout.
render() {
return html`${this.showIcon ? html`<span>icon</span>` : ""}`;
}
import { nothing } from "lit";
render() {
return html`${this.showIcon ? html`<span>icon</span>` : nothing}`;
}
9.2 repeat() for Keyed Lists, .map() for Static
repeat() from lit/directives/repeat.js keeps DOM identity stable across
re-renders when items shift position. Use it for lists the user can reorder, add
to, or remove from. .map() is fine for static read-only lists.
import { repeat } from "lit/directives/repeat.js";
${repeat(
messages,
(msg) => msg.id,
(msg) => html`<div>${msg.text}</div>`
)}
${items.map(item => html`<span>${item.label}</span>`)}
9.3 classMap() and styleMap() Over String Interpolation
String interpolation for classes and styles is fragile and hard to read. Lit
provides directives that handle the bookkeeping.
class=${`g-icon ${rotate ? "rotate" : ""} ${filled ? "filled" : ""}`}
import { classMap } from "lit/directives/class-map.js";
class=${classMap({
"g-icon": true,
rotate: this.isRotating,
filled: this.isFilled,
})}
9.4 Lifecycle Hooks — Know Your Options
| Hook | When | Use for |
|---|
connectedCallback | Element added to DOM | Event listeners, observers, one-time setup |
disconnectedCallback | Element removed from DOM | Cleanup: remove listeners, cancel timers |
willUpdate(changed) | Before rendering, after property changes | Deriving state, computing classes, validation |
firstUpdated | After first render only | Measuring DOM, focusing inputs |
updated(changed) | After every render | Post-render DOM work (scrolling, animations) |
Rules of thumb:
willUpdate > updated for derived state — it runs before paint.
- Always pair
connectedCallback setup with disconnectedCallback teardown.
- Never read layout in
willUpdate (DOM isn't updated yet).
connectedCallback() {
super.connectedCallback();
this.#resizeObserver = new ResizeObserver(this.#onResize);
this.#resizeObserver.observe(this);
}
disconnectedCallback() {
super.disconnectedCallback();
this.#resizeObserver?.disconnect();
}
9.5 Custom Events — The eventInit + static eventName Pattern
Every event class uses a shared eventInit object
({ bubbles: true, cancelable: true, composed: true }) and a static eventName
for listener registration. This is non-negotiable for Shadow DOM traversal.
const eventInit = { bubbles: true, cancelable: true, composed: true };
export class BoardSaveEvent extends Event {
static eventName = "bbboardsave";
constructor(public readonly url: string) {
super(BoardSaveEvent.eventName, { ...eventInit });
}
}
Why composed: true? Without it, events dispatched inside Shadow DOM stop
at the shadow boundary. Every component in this codebase uses Shadow DOM, so
every event that needs to reach a parent must be composed.
Why static eventName? It provides a single source of truth for the string.
Listeners use BoardSaveEvent.eventName instead of a magic string.
9.6 The StateEvent Type-Safe Event Bus
For cross-cutting events that flow through the SCA system, use the typed
StateEvent<T> class. The discriminated union StateEventDetailMap ensures
that every event type has a known payload shape.
this.dispatchEvent(new StateEvent(new Board.Create({ title: "My Board" })));
async (evt?: StateEvent<"board.create">): Promise<void> => {
const { title } = evt!.detail;
};
9.7 @property() vs @state() — Public API vs Internal
@property() is the component's public API — attributes that consumers set.
@state() (or private fields) is internal rendering state.
@customElement("my-widget")
class MyWidget extends LitElement {
@property({ type: Boolean, reflect: true })
accessor active = false;
@state()
accessor #menuOpen = false;
}
Use reflect: true sparingly — only when the attribute needs to be visible in
the DOM for CSS selectors (:host([active])) or accessibility.
9.8 CSS: static styles Is the Only Way
Lit's static styles property enables style sharing and adoptedStyleSheets for
performance. Never use inline <style> tags in templates.
render() {
return html`<style>.foo { color: red }</style><div class="foo">...</div>`;
}
static styles = css`
:host {
display: block;
}
.foo {
color: var(--my-color, red);
}
`;
CSS custom properties are the theming API. Expose --bb-* variables from
:host and consume them internally. This is how child components inherit design
tokens without prop-drilling styles.
The Short Version
Say what you mean. Type parameters over casts. Static imports over
dynamic. Definite assignment over optional chaining. Outcomes over exceptions.
Thin components over fat ones. Named patterns over anonymous fixes.
If the compiler can check it, let it. If a human can name it, name it. If a
tool can enforce it, write the rule.