Skip to main content
@craft-ts
LearnLearn with EffectAI agentsGuideReference
@craft-ts/core@craft-ts/component@craft-ts/dev-tools@craft-ts/effect@craft-ts/style@craft-ts/style-testing@craft-ts/i18n@craft-ts/i18n-effect@craft-ts/mcp
ExamplesMigrationEffect compatibility & maturityAdopting CraftTS progressivelyPress kitRoadmapBacklog
Beta
  • Learn
  • Learn with Effect
  • AI agents
  • Guide
  • Reference
  • Packages
  • Resources
  • Guide overview
  • Create a project
i.Core concepts
  • The mental model
  • Which primitive should I use?
  • Anatomy of a primitive
  • Generators and yield*
  • Insertions
  • Typed insertion pipes
  • Exceptions as values
ii.Managing state
  • Local state
  • State machines
  • query
  • Mutations
  • queryParams
  • asyncProcess
  • Selecting
  • Reacting to mutations
  • Collections
  • Collection utilities
  • Persistence
  • GlobalPersisterHandler
  • Pagination placeholders
  • Schema validation
iii.Recommended approaches
  • Inject at the point of use
iv.Structuring the app
  • craftService
  • Service scopes
  • Shaping the public API
  • Abstract services
  • App start
  • Lazy services
  • Server functions
  • craftRegisterFor
  • Target wrapper
v.Routing & type-safe DI
  • Setup
  • CLI automation
  • ESLint rules
  • Route providers
  • Route guards
  • Route exception handling
  • Non-blocking navigation
  • Global error component
  • Route load errors
  • Scaling routes
vi.Components & templates
  • Components
  • Activating the style system
  • Typed styles
  • Defining a design system
  • Tokens and variables
  • Global foundation and fonts
  • Pseudo-elements and animations
  • Axes and the matrix
  • Context obligations
  • Testing visual states
  • Text contrast
  • Visual attestation
  • Template obligations
  • Typed CSS variables
  • Fine-grained reactivity
  • Progressive rendering
  • Directives and .pipe(...)
  • settledValue & pendingNode
  • Accessibility
  • Customization
  • Content projection
  • Styling a component
  • Template migrator
vii.Forms
  • Overview
  • Validators
  • Submitting
  • Nested forms
  • Exception handling
  • Complete examples
viii.Testing
  • Testing services
  • Testing components
  • Type-level tests
  • Browser boundaries
  • Page stability
  • Architecture rules
    • Overview
    • Declarative baseline
    • Unique identities
    • HTTP endpoint ownership
    • Computed purity
    • Dependency cycles
    • Mutation reactions
    • Route DI proofs
    • Route component files
    • Path boundaries
    • Exclusive links
    • Persisted identities
    • insertSelect keys
    • craftEffect and network
    • craftEffect and imperative sync
    • Interactive element names
    • Metric thresholds
    • Server-state loaders
    • Primitive loader requirements
  • Extensible architecture graph
  • Graph insights
  • Craft graph vs Nx
ix.Reactivity utilities
  • craftComputed
  • craftEffect
  • craftMethod
  • source$
  • on$
  • fromEventToSource$
  • sourceFromEvent
  • afterRecomputation
x.Internationalisation
  • Type-safe i18n
  • The catalogue
  • Tokens
  • The runtime
  • With Effect
xi.Deployment (experimental)
  • Overview
  • Manifest reference
  • Diagnostics
  • Providers
  • Alchemy provider
xii.Going further
  • SSR and hydration
  • Effect integration
  • Program operators
  • Pattern matching
  • Observability
  • AI agents
  • Temporal runtime
  1. Routing & type-safe DI
  2. ESLint rules

ESLint rules#

The rule set is not decoration: several checks in this documentation only work because a rule generated or maintained the code they read. Others enforce the architecture — no hidden runtime dependencies or direct transport calls — and most of them autofix.

Install them once when you set up routing and type-safe DI. Then lean on the quick fixes rather than writing the boilerplate by hand.

An ESLint error is not a compile error

A missing autofix does not break the build. If you skip the quick fix after changing a component's DI shape, main.ts keeps reading a stale GenDeps_* and can miss a real DI error. Run eslint --fix in CI.

The plugin is exposed from @craft-ts/dev-tools/eslint-rules.

Scope-matched craft service inputs#

craft-ts/require-craft-service-input-scope is enabled at error in the recommended and Effect presets, including generated apps. It checks that a service factory does not take inputs that its scope cannot own:

  • global factories have no inputs;

  • toProvide and manuallyProvidedAtRoot factories use only $provided;

  • function factories use call-site inputs only; they have no provider for $provided configuration.

Before — a singleton's value depends on whichever consumer supplies it first:

ts
craftService(
  { name: 'UserQuery', providedIn: 'global' },
  function* (inputs: { userId: CraftServiceInput<string> }) {
    // ...
  },
);

Use function when each call has its own inputs. Use a provider scope when consumers should resolve one configured instance; in a component, pass its inputs through withComponentProviders:

ts
craftService(
  { name: 'ProfileContext', providedIn: 'toProvide' },
  function* (inputs: {
    $provided: { profileId: CraftServiceInput<string> };
  }) {
    // ...
  },
);

Profile.pipe(
  withComponentProviders(({ profileId }) => [
    provideProfileContext({ profileId }),
  ]),
);

The rule reads inline input types and local type aliases. TypeScript also enforces the scope across aliases and service call sites, where the type system knows which generated helper is being called. See service inputs.

There is no autofix: choose whether the service owns shared component/route state (toProvide) or should create a fresh context for each reusable call (function). Pass provider configuration through $provided, or pass function inputs directly to X(...).

Fixed component provider lists#

craft-ts/require-fixed-component-provider-list is enabled at error in the recommended preset, inherited by the Effect preset and newly generated apps. It requires withComponentProviders to receive a concise arrow returning a literal array with fixed entries and order.

Before — rejected because the component's provider set can change:

ts
Profile.pipe(
  withComponentProviders(({ profileId }) =>
    enabled ? [provideProfileContext({ profileId })] : [],
  ),
);

After — keep the provider present and let its service consume reactive values:

ts
Profile.pipe(
  withComponentProviders(({ profileId }) => [
    provideProfileContext({ profileId, enabled }),
  ]),
);

Here enabled is a reactive reader accepted by the service's configuration. Variables holding lists, block-bodied callbacks, spreads, conditional or logical provider selection, and dynamically assembled lists (map, filter, concat) are rejected. Reactive expressions inside provider arguments are allowed. Renamed imports and namespace imports from @craft-ts/component are recognized; unrelated local functions with the same name are ignored.

There is no autofix: the developer must choose which providers always remain present. This is a local syntax guarantee for the declared list, not an analysis of the implementation of each provider helper. See providers configured by component inputs for the service and component example.

Presets and configuration#

The recommended preset bans every TypeScript assertion in authored Craft code, including as const:

ts
import craftRules from '@craft-ts/dev-tools/eslint-rules';

export default [{ files: ['**/*.ts'], ...craftRules.configs.recommended }];

For a project using @craft-ts/effect, the published preset enables the Craft rules and the Effect adapter rule in one entry:

ts
import craftRules from '@craft-ts/dev-tools/eslint-rules';

export default [
  {
    files: ['**/*.ts'],
    ...craftRules.configs.effect,
  },
];

Use craftRules.configs.recommended for projects that do not use Effect.

Add it to your ESLint flat config:

ts
import craftRules from '@craft-ts/dev-tools/eslint-rules';

export default [
  // keep your existing ESLint config entries
  {
    files: ['**/*.ts'],
    plugins: {
      'craft-ts': craftRules,
    },
    rules: {
      'craft-ts/prefer-craft-template-blocks': 'error',
      'craft-ts/no-render-writes': 'error',
      'craft-ts/require-reactive-template-bindings': 'error',
      'craft-ts/no-craft-use': 'error',
      'craft-ts/no-craft-service-return': 'error',
      'craft-ts/require-craft-service-input-scope': 'error',
      'craft-ts/no-craft-component-return-type': 'error',
      'craft-ts/require-craft-component-for-exported-node-factory': 'error',
      'craft-ts/no-raw-craft-router-url': 'error',
      'craft-ts/no-type-assertions-in-template': 'error',
      'craft-ts/no-explicit-craft-template-return-type': 'error',
      'craft-ts/no-extracted-craft-component-parts': 'error',
      'craft-ts/require-direct-craft-component-template': 'error',
      'craft-ts/no-ephemeral-template-form-state': 'error',
      'craft-ts/require-form-for-input-action': 'error',
      'craft-ts/template-element-name-unique': 'error',
      'craft-ts/no-craft-computed-side-effects': 'error',
      'craft-ts/no-external-state-transition': 'error',
      'craft-ts/require-craft-method-for-yieldable-callback': 'error',
      'craft-ts/prefer-direct-yieldable-callback': 'error',
      'craft-ts/prefer-deep-yieldable-for-item': 'warn',
      'craft-ts/require-yieldable-reactive-read': 'error',
      'craft-ts/require-yieldable-template-method': 'error',
      'craft-ts/require-yieldable-insertion-write': 'error',
      'craft-ts/no-craft-service-component-same-file': 'error',
      'craft-ts/max-craft-declarations-per-file': 'error',
      'craft-ts/max-craft-component-lines': 'warn',
      'craft-ts/prefer-craft-http-transport': 'error',
      'craft-ts/no-injection-token': 'error',
      'craft-ts/require-primitive-derived-property': 'error',
      'craft-ts/no-reused-primitive-method': 'error',
      'craft-ts/no-async-await': 'error',
      'craft-ts/no-throw': 'error',
      'craft-ts/no-imperative-craft-resource-trigger': 'error',
      'craft-ts/no-imperative-craft-method-actions': 'error',
      'craft-ts/no-remote-work-in-craft-method': 'error',
      'craft-ts/no-type-assertions-in-resource-loader': 'error',
      'craft-ts/no-explicit-resource-loader-type': 'error',
      'craft-ts/no-explicit-craft-insertion-type': 'error',
      'craft-ts/no-craft-primitive-type-assertion': 'error',
      'craft-ts/prefer-insert-deep-yieldable': 'error',
      'craft-ts/no-imperative-template-action-chain': 'error',
      'craft-ts/prefer-route-query-params-for-filter-state': 'warn',
      'craft-ts/no-imperative-storage-in-craft-method': 'error',
      'craft-ts/no-transition-actions': 'error',
      'craft-ts/require-craft-resource-trigger-yield': 'error',
      'craft-ts/require-assert-exhaustive-route-exceptions': 'error',
      'craft-ts/require-craft-exception-handler': 'error',
      'craft-ts/require-catch-tag-exhaustive-reaction': 'error',
      'craft-ts/require-exception-component-di-check': 'error',
      'craft-ts/require-pending-component-di-check': 'error',
      'craft-ts/require-child-route-mount-check': 'error',
      'craft-ts/require-lazy-load-with-retry': 'error',
      'craft-ts/global-exception-registry-match': 'error',
    },
  },
];

What each rule does:

  • craft-ts/prefer-craft-template-blocks: keeps craftComponent(...) templates declarative by rejecting ternaries, boolean logical expressions, negations, and imperative control flow; nullish value fallbacks remain valid. Use ifNode(...), matchNode.exhaustive(...), forNode(...), or deferNode(...) for render control flow

  • craft-ts/require-craft-computed-for-dynamic-template-lookup: rejects dynamic object or array lookups in a Craft template when the lookup key comes from a reactive member; move the lookup to a named craftComputed() in the component's service and bind that value directly

  • craft-ts/no-render-writes: rejects detectable set(), update(), and mutate() calls in component templates and render bindings while allowing DOM event and onXxx output callbacks

  • craft-ts/no-external-state-transition: rejects generic replace, set, update, or patch calls on a value returned by Craft state(...) outside its state insertion. Put the transition behind a named state method that accepts intent and computes the next value internally.

  • craft-ts/require-reactive-template-bindings: requires signals, named Craft values, and component inputs to be read inside granular binding callbacks instead of during VNode construction; static values and message-catalog interpolation helpers remain valid

  • craft-ts/no-craft-use: forbids the synchronous craftUse(...) escape hatch in Craft TypeScript files. Declare primitive dependencies with yield* in a service, method, or effect body; component templates are synchronous and consume the resulting view API directly.

  • craft-ts/no-craft-service-return: forbids returning a value from a craftService factory. Its API is assembled from the named values yielded by its generator; use yield* craftPrivate(...) for primitives that should stay internal, and craftExpose(name, value) for standalone values that belong in the API. The rule autofixes simple returned objects whose properties already match yielded primitive names.

  • craft-ts/require-craft-service-input-scope: keeps global services input-free, limits provider-scoped services to $provided, and checks function-scoped inputs. TypeScript also rejects invalid service-helper bindings at call sites.

  • craft-ts/prefer-direct-craft-service-exposure: reports craftExpose(...) wrappers that repeat a yielded service's name, a named primitive result, or a member already returned by a primitive such as state, including when the member is renamed; keep the existing nested path (for example, View.searchInput.setSearchInput). It also reports service property shortcuts that can be yielded directly and forbids craftExpose inside supported primitive declarations or callbacks. Keep craftExpose for standalone or computed values.

  • craft-ts/prefer-craft-computed-for-reactive-generator: reports a zero-argument generator exposed as a service value when its only statement returns a value derived from a yield* read. Declare that value with yield* craftComputed(name, function* () { ... }); parameterized generators and multi-step workflows are unaffected.

  • craft-ts/require-craft-component-for-exported-node-factory: requires an exported function that directly returns a Craft node, such as button(...), to be declared with craftComponent(...) so Craft directives and composition remain available

Avoid flattening primitive members with craftExpose#

Members returned from a named primitive are already available below that primitive in the service API. Re-exporting one with craftExpose creates a second, flat access path and can make the two paths drift apart.

Before — the method is already available through searchInput:

ts
const searchInput = yield* state('searchInput', '', ({ set }) => ({
  setSearchInput: (value: string) => set(value),
}));

// ❌ duplicates searchInput.setSearchInput on the service API
yield* craftExpose('setSearchInput', searchInput.setSearchInput);

After — keep the member under its primitive and update consumers to use that path:

ts
const searchInput = yield* state('searchInput', '', ({ set }) => ({
  setSearchInput: (value: string) => set(value),
}));

yield* View.searchInput.setSearchInput(value);

The rule catches these members even when the first craftExpose argument renames them. It also catches methods and properties returned by query, mutation, asyncProcess, and queryParams. It only reports when the matching member is visible in the primitive's returned object and the primitive is publicly yielded. eslint --fix does not rewrite this: update consumers to the nested path, then remove the redundant craftExpose call.

Small node factories are valid when they stay private to the file:

ts
function filterButton(filter: TodoFilter, label: string) {
  return button('todoFilterButton', { type: 'button' }, label);
}

Once the function is exported, use a Craft component so directives and composition can be applied at the module boundary:

ts
// ❌ craft-ts/require-craft-component-for-exported-node-factory
export function filterButton(filter: TodoFilter, label: string) {
  return button('todoFilterButton', { type: 'button' }, label);
}

// ✅
export const FilterButton = craftComponent(
  'FilterButton',
  {},
  ({
    label,
  }: {
    readonly filter: Input<TodoFilter>;
    readonly label: Input<string>;
  }) => button('todoFilterButton', { type: 'button' }, label),
);

The rule also follows named exports such as export { filterButton } and checks exported arrow functions.

  • craft-ts/no-type-assertions-in-template: forbids as ... and angle-bracket type assertions in Craft templates; fix the type in the component's service or expose a correctly typed derived value

  • craft-ts/no-explicit-craft-template-return-type: forbids explicit return annotations on render callbacks inside craftComponent(...). A broad annotation such as (): CraftNodeChildren widens the concrete node type, breaks dependency and type-safe DI inference, and can surface as a runtime error. Let the callback return type be inferred:

    ts
    const pendingStatusMessage = (message: string) => p(message);
    
    // ❌ The annotation erases the concrete node/dependency information.
    pendingNode({
      fallback: (): CraftNodeChildren => pendingStatusMessage('Loading…'),
      reloading: (): CraftNodeChildren => pendingStatusMessage('Reloading…'),
    });
    
    // ✅ The concrete `p(...)` node stays visible to Craft's inference.
    pendingNode({
      fallback: () => pendingStatusMessage('Loading…'),
      reloading: () => pendingStatusMessage('Reloading…'),
    });
    

    The rule is autofixable with eslint --fix. Return annotations on DOM event and output callbacks remain allowed because those callbacks do not produce rendered children.

  • craft-ts/no-extracted-craft-component-parts: requires the function passed to craftComponent(...) to stay inline. Keeping it at the component boundary preserves contextual type inference and makes the component's behaviour readable in one place.

    Before — an extracted ReviewTemplate hides the component's two halves behind names at the call site:

    ts
    // ❌ craft-ts/no-extracted-craft-component-parts
    const ReviewTemplate = craftTemplate(({ decide }) =>
      div([button({ click: decide }, 'Review')]),
    );
    
    export const ReviewApp = craftComponent('ReviewApp', {}, ReviewTemplate);
    

    After — keep a concise template lambda in the component call:

    ts
    // ✅
    export const ReviewApp = craftComponent('ReviewApp', {}, () =>
      div([button('review', { type: 'button' }, 'Review')]),
    );
    

    The rule only rejects an identifier in the template argument position. The direct concise arrow is the supported form for a component template; use the returned node expression inline so craftComponent(...) can contextually type it.

  • craft-ts/require-direct-craft-component-template: requires the third argument to craftComponent(...) to be a concise arrow function whose body directly constructs a template node or node array. Keep service setup, destructuring, aliases, and other statements outside the component template; expose the values the template needs through its view API. The lambda's parameters may be named inputs, but may not destructure them. Render and event callbacks inside the returned tree are unaffected.

    Before — the component factory resolves a service, destructures its result, and only then builds the template:

    ts
    // ❌ craft-ts/require-direct-craft-component-template
    const PixelArt = craftComponent(
      'PixelArt',
      { providers: [providePixelArtView()] },
      function* () {
        const { cells, renderedPixelGrid } = yield* PixelArtView();
        return section({ class: example.card }, [
          button('clear', { click: cells.clearAll }, 'Clear'),
          div({ class: pixel.grid, role: 'grid' }, renderedPixelGrid),
        ]);
      },
    );
    

    After — return the section(...) directly and read each value where it is used. This form assumes PixelArtView exposes those members on its view API:

    ts
    // ✅
    const PixelArt = craftComponent(
      'PixelArt',
      { providers: [providePixelArtView()] },
      () =>
        section({ class: example.card }, [
          button('clear', { click: PixelArtView.cells.clearAll }, 'Clear'),
          div(
            { class: pixel.grid, role: 'grid' },
            PixelArtView.renderedPixelGrid,
          ),
        ]),
    );
    
  • craft-ts/no-ephemeral-template-form-state: forbids let / const / var in a craftComponent(...) or craftDirective(...) template. Component templates now return their node tree directly, so state and derived values belong in a view service; directive generator templates must also avoid render-local state because they can rerun.

  • craft-ts/require-form-for-input-action: rejects a button's direct mutate(...) or method(...) call when it consumes an input-bound value, including through a local record or variable; use insertForm, insertFormAttributes, and insertFormSubmit for mutation-backed forms, then submit a native form(...) with a type: 'submit' button

  • craft-ts/template-element-name-unique: requires named HTML helpers to use a static, unique local name within a component; use the object-first helper form for unnamed elements such as p({ id: 'hint' }, ...)

  • craft-ts/no-craft-computed-side-effects: forbids writes and asynchronous work inside craftComputed; only reactive reads and settled(...) are allowed. The graph-wide counterpart is assertCraftComputedPure.

  • craft-ts/no-effect-outside-loaders: keeps params, methods, craftComputed(...), and craftEffect(...) synchronous by allowing Effect values and Effect service reads only in Effect loaders; no-effect-in-params remains as a compatibility alias

  • craft-ts/sync-effect-body: keeps a body declared synchronous (SyncOp in its requirements) free of anything that may suspend — async constructors such as Effect.sleep/Effect.promise, and members nothing declares synchronous. Type-aware: the ESLint parser must use projectService: true or a TypeScript project

  • craft-ts/no-explicit-effect-type: lets Effect.gen infer its complete type instead of repeating an explicit Effect annotation; contracts declared in interfaces and type aliases remain allowed

  • craft-ts/prefer-inline-effect-insertion: keeps the queryEffect insertion factory inline so its resource and exception types are inferred without a separate InsertionParams context alias

  • craft-ts/prefer-inline-route-providers: inlines a route provider tuple used only once by loadCraftComponent(...), preserving the route-level type proof

  • craft-ts/prefer-craft-reactivity: rejects authored signal/computed/effect/resource APIs, explicit .subscribe() calls, and RxJS Subject/BehaviorSubject/ReplaySubject; use state, craftComputed, craftEffect, query, and named source$/on$ flows

  • craft-ts/prefer-craft-service: keeps services in the craftService(...) model

  • craft-ts/no-craft-service-component-same-file: forbids declaring craftService(...) and craftComponent(...) in the same file; a route-level service provider combined with a lazy-loaded component can break lazy loading, so keep them in separate files

  • craft-ts/max-craft-declarations-per-file: reports the third and subsequent craftComponent(...), craftService(...), or craftDirective(...) declaration of the same kind in a file; keep Craft entities split across focused files

  • craft-ts/max-craft-component-lines: reports a file that declares a craftComponent(...) once it exceeds 700 non-import lines (import statements and blank lines are not counted, so a component with many dependencies is not penalized for its import block). A file this long usually mixes business logic, view logic, and markup that could live in separate, independently testable units:

    ts
    // ❌ craft-ts/max-craft-component-lines
    // review-app.ts — 3894 lines: filtering, sorting, diff computation,
    // pagination, and the full markup tree inlined in one component.
    export const ReviewApp = craftComponent(
      'ReviewApp',
      {},
      function* (inputs: { readonly subjects: Input<Subject[]> }) {
        const filtered = yield* craftComputed(
          'filtered',
          () => /* 80 lines */ [],
        );
        const diff = yield* craftComputed(
          'diff',
          () => /* 150 lines of diffing */ null,
        );
        // …dozens more computeds and craftMethods…
    
        return div(
          {},
          /* a thousand-plus lines of markup for the filter bar, the diff
             viewport, the review card list, and the pagination controls */
        );
      },
    );
    
    // ✅ Business logic moves to a craftService; independent template
    // regions become their own craftComponent, each testable and readable
    // on its own.
    export const { ReviewFilters } = craftService(
      { name: 'ReviewFilters', providedIn: 'global' },
      function* () {
        yield* craftExpose(
          'filter',
          (subjects: Subject[], criteria: FilterCriteria) => /* … */ [],
        );
      },
    );
    
    export const SubjectDiffViewport = craftComponent(
      'SubjectDiffViewport',
      {},
      ({ subject }: { readonly subject: Input<Subject> }) => div({} /* … */),
    );
    
    export const ReviewApp = craftComponent(
      'ReviewApp',
      { providers: [provideReviewAppView()] },
      function* (inputs: { readonly subjects: Input<Subject[]> }) {
        const { filtered } = yield* ReviewAppView(inputs);
    
        return div(
          {},
          forNode(filtered, (subject) => SubjectDiffViewport({ subject })),
        );
      },
    );
    

    Set a project-specific threshold with ['warn', { max: 600 }] if 700 lines is still too generous for your team.

  • craft-ts/no-injection-token: forbids authored InjectionToken contracts; declare them with craftService({ name, providedIn: 'abstract' }, abstract<Contract>())

  • craft-ts/prefer-craft-http-client: forbids direct transport usage in favor of CraftHttpClient

  • craft-ts/prefer-craft-http-transport: forbids direct fetch() and XMLHttpRequest because they bypass typed responses and exceptions, tracing, cancellation, and the architecture graph; use query() for reads or mutation() for writes with CraftHttpClient, or CraftBinaryHttpClient for raw binary bodies

  • craft-ts/prefer-craft-input-output: keeps component inputs and outputs in the Input/Output model used by craftComponent(...)

  • craft-ts/require-primitive-derived-property: requires a computed or craftComputed that only depends on one primitive in the same component/service to be exposed by that primitive's insertion; simple cases are autofixed

  • craft-ts/no-reused-primitive-method: requires an exposed primitive insertion method to have one call site per file, including unchanged aliases; naming an alias is not a call site, binding it is. Create a context-specific insertion method for each distinct use

  • craft-ts/no-async-await: forbids async functions, await, and for await...of because native Promise suspension hides Craft dependencies and can lose cancellation or exception tracking; use generator-based Craft primitives, craftSleep, and CraftHttpClient instead

  • craft-ts/require-loop-sleep-or-stop: requires a while / do / for driven by yield* or true to suspend on craftSleep (cancelled with its owner) or to have a break, return or throw; without either it re-runs as fast as the runtime allows. See page stability

  • craft-ts/require-generator-resource-loader: requires query, mutation, and asyncProcess loaders to be generator functions because a plain or async return hides remote dependencies from the resource lifecycle; use yield* to keep each suspension tracked

  • craft-ts/no-throw: forbids throw in Craft code because it bypasses the typed resource exception channel, and offers a Quick Fix that returns craftException({ _tag: 'UNEXPECTED_ERROR' }, { error: ... }); keep technical boundaries and tests outside this rule when their contracts require thrown errors

  • craft-ts/no-imperative-craft-resource-trigger: forbids query.call(...), mutation.mutate(...), and asyncProcess.method(...) in a craftEffect dependency graph, including through craftGen(...). The graph-wide counterpart, including state / source$ writes, is assertCraftEffectNoImperativeSync.

  • craft-ts/no-imperative-craft-method-actions: forbids composing multiple imperative actions in a craftMethod; emit a source$ event and let the affected query react with insertReactOnMutation(...) instead. A handler such as event.preventDefault() followed by one mutation.mutate(...) remains valid.

  • craft-ts/no-event-only-craft-method: errors by default when a craftMethod only calls preventDefault(), stopPropagation(), or stopImmediatePropagation() and then delegates to one action. Bind the action with eventAction(...) on the element. The default architecture check enforces the same rule across files.

  • craft-ts/no-remote-work-in-craft-method: forbids CraftHttpClient.*(...) inside craftMethod because that action boundary does not own request loading, cancellation, exceptions, or graph dependencies; define the request directly in the query or mutation loader.

  • craft-ts/no-type-assertions-in-resource-loader: forbids as ... and angle-bracket assertions inside query, mutation, and asyncProcess loaders because assertions only silence TypeScript and can hide Promise, response, or transport mismatches; repair the request or adapter typing instead.

  • craft-ts/no-type-assertions-in-craft-code: forbids TypeScript type assertions in authored Craft code, including as const and angle-bracket assertions; the narrow undefined as T | undefined seed is allowed for intentionally optional state values. Use correct API typing or satisfies for shape validation. Low-level technical adapters may disable this rule locally when an explicit runtime boundary cast is unavoidable.

  • craft-ts/no-explicit-resource-loader-type: forbids explicit parameter and return annotations on query, mutation, and asyncProcess loaders; let the resource infer its contract from params, method, and the yielded operations instead of writing Generator<...> or { params: string }

  • craft-ts/no-explicit-craft-insertion-type: forbids explicit parameter and return annotations on callbacks passed to insert*Pipe; let the primitive infer the insertion context and derived output

  • craft-ts/no-craft-primitive-type-assertion: forbids chained assertions such as as unknown as Generator<...> around Craft primitive generators, which can hide the inferred output and dependency contract

  • craft-ts/prefer-insert-deep-yieldable: rejects adapting a property of a primitive result with deepYieldable(...); add insertDeepYieldable() to the primitive and read the property directly

  • craft-ts/no-imperative-template-action-chain: forbids chaining multiple Craft actions in one template event callback, or in a generator passed as a component prop (trigger: function* () { … }); emit one source$ event and let the query, mutation, and state react through on$. With type information, any yield* of a service method counts as an action (yield* View.label.recordConfirm()), whatever its name; yield* of a reactive reader does not (const user = yield* View.user()), so a handler can read as many values as it needs around a single action

  • craft-ts/prefer-route-query-params-for-filter-state: warns when a local state() is used directly or through a local derivation as params for query, queryEffect, asyncProcess, or asyncProcessEffect; use queryParams() for values that should survive reloads and be represented in the URL. The graph-wide counterpart, which also sees cross-file dependencies, is assertResourceParamsPreferQueryParams.

  • craft-ts/no-imperative-storage-in-craft-method: forbids direct storage access and imperative location changes in a craftMethod; use insertReactOnMutation(...) with optimisticUpdate: () => undefined to clear the affected query and let its persistence follow the query state.

  • craft-ts/no-transition-actions: forbids query.call(...), mutation.mutate(...), and asyncProcess.method(...) inside transitionStep(...); validate the event and emit a source, then let the resource react to that source.

  • craft-ts/require-craft-resource-trigger-yield: requires those triggers to use yield* inside generator functions, while ordinary UI callbacks may keep imperative calls

  • craft-ts/require-craft-method-for-yieldable-callback: requires callbacks returned by a craftComponent factory to wrap yieldable Craft method calls in craftMethod(...)

  • craft-ts/prefer-direct-yieldable-callback: replaces a template generator or generator method that only delegates yield* callback() with the callback reference itself (callback or object.method)

  • craft-ts/prefer-deep-yieldable-for-item: warns when a forNode item is read repeatedly through yield* item() property accesses; expose a named insertDeepYieldable('property') collection and use direct item property readers

  • craft-ts/require-yieldable-reactive-read: requires Craft reactive readers to be delegated with yield* inside generator functions; a named ifNode condition callback may return one reader invocation directly

  • craft-ts/require-yieldable-template-method: requires yieldable Craft method calls in a craftComponent template to be delegated with yield*, or passed as a reference (click: counter.increment)

  • craft-ts/require-yieldable-insertion-write: requires set(...), patch(...), and update(...) to be delegated with yield* when they are used inside a generator method

  • craft-ts/require-assert-exhaustive-route-exceptions: adds the collection-level assertExhaustiveRouteExceptions(...) safety net

  • craft-ts/require-craft-exception-handler: enforces craftExceptionHandler(function* (...) {}); simple handlers are autofixed and ambiguous raw redirects are reported for manual migration

  • craft-ts/require-catch-tag-exhaustive-reaction: rejects empty catchTag.exhaustive handlers and bare return; statements so each caught exception triggers a real reaction

  • craft-ts/require-exception-component-di-check: generates O(1) RouteExceptionComponentCheckedDI checks for renderComponent, route-level errorComponent, withErrorComponent, withRouteLoadError, and route-local provideRouteLoadErrorComponent

  • craft-ts/require-pending-component-di-check: generates the independent RouteCheckedDI check for each pendingComponent

  • craft-ts/no-raw-class: requires every class: binding — on an element, in attrs, on a component host — to trace back to a sheet imported from a *.style module: sheet.key, a const bound to one, an array of them, a typed input (a parameter or a member of one), or a function that only returns one. A string, a template literal, a conditional or an object of booleans is refused. A class assembled at render time is a visual state nothing recorded, so the visual matrix would enumerate what the sheets declare while the DOM shows something else; and a sheet declared outside a *.style.ts is never evaluated by the build, so its class has no CSS. Make the variation an axis and set a data-* attribute

  • craft-ts/no-inline-style: restricts style: to assign(...) from @craft-ts/style — or an array of them, an object spreading only them, a conditional whose branches are all of them, or a function that only returns them. What varies at runtime is a typed variable (cssVars + assign), read by a sheet; attrs.style is always refused

  • craft-ts/no-component-css: forbids every .css import except virtual:craft-style.css. Global rules go in craftGlobalStyles, fonts in defineFont

  • craft-ts/no-forbidden-eslint-disable: requires a reason on every directive that disables a design-system rule — // eslint-disable-next-line craft-ts/no-raw-class -- markdown output carries its own classes. The reason is what the reviewer decides on in Review Attest. It also forbids disabling the rules listed in .craft/eslint-disable-policy.json. A blanket eslint-disable silences this rule too, so it cannot be reported here; Review Attest lists it

  • craft-ts/no-raw-css-value: forbids a string or number literal as an argument to a @craft-ts/style helper — p('12px'), bg('red'). If the scale is missing the step, add it to the scale; if the value genuinely cannot be proven, unsafeLength('13px', reason) compiles and makes the debt countable in the graph

  • craft-ts/no-free-has: forbids a hand-written :has() in styles. It reaches across the component boundary, so what a component looks like depends on markup it does not own — a state the matrix cannot enumerate. Use the descendant axis, which is a closed set and carries its own test driver

  • craft-ts/style-file-boundary: restricts a *.style.ts to style-vocabulary imports. The build plugin imports the file in Node to read what it registered, so an application import would run application code at build time

  • craft-ts/require-effect-adapters: requires the Effect-aware adapters — queryEffect, mutationEffect, asyncProcessEffect, and transitionGuardEffect — instead of the plain primitives and transitionGuard in an Effect application. See Choose the right adapter

  • craft-ts/craft-signal-source-name-match: requires signalSource(name, ...) to take a string literal matching the variable, class property or object property it is assigned to, so the name in a trace is the name in the source. A computed name defeats the architecture graph, which reads these names statically

  • craft-ts/require-child-route-mount-check: adds the missing assertChildRouteMounts(...) call + import (Quick Fix) for any craftRoutes(...) collection that mounts lazy loadChildren, so a .withParent-pinned child mounted under the wrong path is a compile error

  • craft-ts/require-lazy-load-with-retry: wraps route loadComponent and loadChildren imports with the generated withRetry(...) loader helper while preserving a statically analyzable import specifier

  • craft-ts/global-exception-registry-match: keeps CraftGlobalExceptionRegistry synchronized with handlers delegating to globalError()

  • craft-ts/prefer-craft-router-link: requires CraftRouterLink for internal a(..., { href: ... }) navigation; external URLs, fragment links, downloads, _blank, and links marked with data-navigation: 'external' remain native

  • craft-ts/no-raw-craft-router-url: rejects reading CraftRouter.url; use the typed route parameter helper generated by craftRoutes(...) instead of parsing the URL

  • craft-ts/no-craft-component-return-type: rejects explicit annotations on craftComponent(...) results so dependency and template inference remains intact

Promise and transport boundaries#

These rules protect the same boundary: asynchronous work must remain visible to the Craft resource that owns it. A native Promise may eventually resolve, but it does not describe which Craft dependencies were read, where suspension occurred, or which resource should be cancelled and receive the exception.

Keep resource loaders generator-based#

ts
// Incorrect: the native Promise hides the request from the Craft lifecycle.
query('usersQuery', {
  loader: async () => (await fetch('/api/users')).json(),
});

// Correct: the resource owns a tracked, yieldable request.
query('usersQuery', {
  loader: function* () {
    return yield* CraftHttpClient.get(({ response }) => ({
      url: '/api/users',
      success: response<User[]>(),
    }));
  },
});

no-async-await rejects async, await, and for await...of in Craft code. require-generator-resource-loader additionally checks that query, mutation, and asyncProcess loaders are generators. Use yield* for Craft operations so every suspension stays tracked.

The loader signature should also stay inferred:

ts
// Incorrect: these annotations can mask a mismatch in the resource contract.
loader: function* ({ params }: { params: string }): Generator<Yielded, Result, unknown> {
  return yield* client({ token: params });
}

// Correct: infer params and the generator result from the resource and body.
loader: function* ({ params }) {
  return yield* client({ token: params });
}

no-explicit-resource-loader-type reports only annotations on the loader signature. Type annotations for local variables and function contracts outside the loader remain allowed.

Keep transport and types honest#

ts
// Incorrect: direct fetch bypasses Craft response/error tracking.
const result = await fetch('/api/users');

// Correct: use the Craft client in the owning resource loader.
return (
  yield *
  CraftHttpClient.get(({ response }) => ({
    url: '/api/users',
    success: response<User>(),
  }))
);

For a raw binary body, use CraftBinaryHttpClient.put(...); do not use a type assertion to force CraftHttpClient to accept a Blob. An assertion only silences TypeScript — it does not change the runtime value or transport. That is why prefer-craft-http-transport and no-type-assertions-in-resource-loader report these patterns.

Preserve primitive inference#

The insertion callback already receives a contextual type, and the primitive already knows the complete type of its generator. Do not repeat either type at the boundary:

ts
// ❌ craft-ts/no-explicit-craft-insertion-type
insertQueryPipe(
  ({ resource }): SpaceQueryView => ({
    items: craftUse(craftComputed('items', () => resource.value())),
  }),
);

// ❌ craft-ts/no-craft-primitive-type-assertion
const generator = query('spaceItems', config) as unknown as Generator<
  unknown,
  SpaceQueryRef,
  unknown
>;

// ✅
const generator = query(
  'spaceItems',
  config,
  insertQueryPipe(({ resource }) => ({
    items: craftUse(craftComputed('items', () => resource.value())),
  })),
);

The assertion is especially harmful around a composed insertion pipe: it replaces the type that carries the derived properties and their dependencies.

Prefer primitive deep-yieldable insertions#

When a property is read from the result of a primitive, expose the deep view at the primitive boundary. This keeps the property reader connected to the primitive and avoids an extra adapter:

ts
// ❌ craft-ts/prefer-insert-deep-yieldable
const spaceQuery = yield * spaceQueryGenerator;
const deepItems = deepYieldable(spaceQuery.items);

// ✅ add insertDeepYieldable() to the query call, then:
const spaceQuery = yield * spaceQueryGenerator;
const items = spaceQuery.items;

Expected failures should use craftException(...) so they remain typed and available through the resource's exception state. no-throw keeps technical throws limited to explicit adapter boundaries, where they can be translated into the Craft exception channel.

Accessibility (craft-ts/a11y)#

Spread craftRules.configs.a11y.rules to enable the WCAG 2.2 AA preset as error. The rules walk all hyperscript in the file (craftTemplate, extracted factories, h('tag')), not only craftComponent argument 3.

  • prefer-named-html-helpers: forbids h('img') / h('button') when a named helper exists

  • require-interactive-local-name: requires a string-literal first argument on interactive helpers; the local name is the third segment of data-craft-name="${component}:${tag}:${localName}"

  • img-has-alt, iframe-has-title, button-has-type, anchor-has-href

  • control-has-accessible-name, label-has-associated-control, heading-has-content

  • no-noninteractive-element-interactions, no-positive-tabindex

  • valid-aria, role-has-required-aria, target-blank-noopener

  • prefer-relative-heading, require-route-heading-outline, require-outlet-heading-section, no-heading-level-skip

See Accessibility.

The two migration rules also expose a VS Code ESLint Quick Fix suggestion that inserts a temporary local disable comment with the intended migration note when you need to unblock a file before doing the full refactor.

The template and reactivity rules are intentionally diagnostic-only: replacing a resource or subscription can change lifecycle and error semantics, so the rule points at the Craft primitive without applying a potentially unsafe rewrite.

Why templates use blocks#

Craft template blocks preserve the branch structure in the type-level render contract. A ternary or condition && node produces only a computed value, so the type checker cannot assert which branch renders which content. Keep derived values and business decisions in the component's state/query layer, then make the template express visibility explicitly:

ts
ifNode(
  isReady,
  () => p('Ready'),
  () => p('Loading…'),
);

matchNode.exhaustive(query.exceptions, '_tag', {
  NOT_FOUND: () => p('Not found'),
  FORBIDDEN: () => p('Forbidden'),
});

This rule is for Craft's TypeScript templates. It does not rewrite external template languages.

The same restriction applies to boolean expressions. A negation is still application logic, even when it is used only for a DOM property:

ts
// Incorrect: the template derives the disabled state.
button(
  {
    disabled: function* () {
      return !(yield* machine.canGoBack());
    },
  },
  'Back',
);

// Correct: derive it with a named computed and bind the result.
const backDisabled = craftUse(
  craftComputed('backDisabled', function* () {
    return !(yield* history.canGoBack());
  }),
);
return { backDisabled };

Keep the template to layout and binding. Move labels, formatted values, validation state, and other decisions into state() or craftComputed().

Derived values belong to their primitive#

When a computed reads only one local primitive, declare it in that primitive's insertion. This keeps the dependency visible and lets pending/exception boundaries name the actual source:

ts
const users =
  yield *
  query('users', config, ({ resource }) => ({
    total: craftUse(
      craftComputed('total', function* () {
        return (yield* settled(resource)).length;
      }),
    ),
  }));

Do not create craftComputed('total', ...) beside the query when the computation depends only on users.

Keep casts and synchronous reads out of templates#

Craft templates reject both as ... / angle-bracket assertions and craftUse(...). Fix the type or perform the synchronous-to-reactive conversion in the component logic, then expose a typed reader or generator to the template:

ts
const typedStep = machine.stepState as unknown as () => { step: Step };
return { typedStep };

// Template: no cast and no craftUse.
matchNode.exhaustive(typedStep, 'step', steps);

no-craft-use applies to Craft TypeScript files, not only the fourth craftComponent(...) argument. A synchronous integration boundary may opt out locally when its external API cannot consume a generator, but application state and templates should use yield*.

Form and accessibility diagnostics#

The accessibility preset also checks the static structure of hyperscript:

  • give every label an htmlFor matching the control id, or wrap the control;

  • give named controls and helpers a unique string local name;

  • use button or a for interactions instead of adding click to a div;

  • add a prefers-reduced-motion branch whenever component CSS defines an animation or transition.

These checks run on Craft TypeScript templates and extracted helper factories, so moving markup into a local function does not bypass them.

Reactive values belong in binding callbacks#

require-reactive-template-bindings uses TypeScript type information to find reactive reads. Reading a signal while constructing a VNode would make it a dependency of the structural component render, so the rule rejects this form:

ts
// Incorrect: count is read by the component template.
p(`Count: ${count()}`);
button({ disabled: isDisabled() }, 'Save');
div({ class: { active: isActive() } });

Keep each read inside the callback owned by its DOM binding. Pass a yieldable reader, or use a generator when the binding must format:

ts
p(count);
p(function* () {
  return `Count: ${yield* count()}`;
});
button({ disabled: isDisabled }, 'Save');
div({ class: isActiveClass });

Literal and otherwise static values are still allowed, as are reads performed from DOM events and onXxx output callbacks. Because the rule is type-aware, the ESLint parser must use projectService: true or a TypeScript project.

Pass simple yieldable callbacks directly#

prefer-direct-yieldable-callback removes a generator wrapper when the template only delegates one zero-argument callback. It handles both a value binding and a generator method:

ts
// Before: redundant wrappers around the callbacks.
button(
  {
    *click() {
      yield* press();
    },
  },
  function* () {
    return yield* label();
  },
);

// After `eslint --fix`.
button({ click: press }, label);

Member callbacks are supported as well when the access is static and has no arguments:

ts
// Before.
span(function* () {
  return yield* counter.increment();
});

// After.
span(counter.increment);

The rule leaves callbacks with parameters, extra statements, or additional computation unchanged. In those cases the generator contains behavior that cannot be represented by passing the callback reference alone.

Prefer deep-yieldable forNode items#

prefer-deep-yieldable-for-item detects when a component reads several properties from the same forNode item through repeated yield* item() calls. Keep the original collection available, and expose a named deep-yieldable view for the component:

ts
import { insertDeepYieldable, state } from '@craft-ts/core';

// Before: every property read yields the whole item again.
forNode(catalog.products, { track: (product) => product.id }, (product) =>
  article([
    span(function* () {
      return (yield* product()).category;
    }),
    span(function* () {
      return (yield* product()).name;
    }),
  ]),
);

// After: the named view keeps each property read lazy and reactive.
const catalog =
  yield * state('catalog', { products }, insertDeepYieldable('products'));

forNode(
  catalog.deepYieldableProducts,
  { track: (product) => product.id },
  (product) => article([span(product.category), span(product.name)]),
);

The rule is diagnostic-only because choosing the insertion belongs to the primitive that owns the collection. insertDeepYieldable('products') leaves catalog.products unchanged and adds catalog.deepYieldableProducts.

Yield insertion writes from generator methods#

require-yieldable-insertion-write requires set(...), patch(...), and update(...) calls to be delegated with yield* when they are used inside a generator method:

ts
nextPage: function* () {
  const current = yield* state();
  return yield* patch({ page: current.page + 1 });
},

Insertion callbacks that are not generators may return a write directly; the insertion wrapper consumes that result for them.

What generates what#

Three rules do more than complain — they write code you would otherwise maintain by hand:

RuleGenerates
require-assert-exhaustive-route-exceptionsthe collection-level exhaustiveness assert
require-child-route-mount-checkthe assertChildRouteMounts(...) call and its import
require-lazy-load-with-retrythe withRetry(...) wrapper on lazy route imports
prefer-direct-yieldable-callbackreplaces redundant generators with direct callback references

Adopting them progressively#

On an existing codebase, enable them in waves rather than all at once:

  1. The route safety nets — the require-* rules. Mostly autofixable. They generate the proofs; architecture tests (assertRouteDiProofs) fail CI if a proof is later removed or left unarmed.

  2. The architecture rules last — prefer-craft-service, no-craft-service-component-same-file, prefer-craft-http-client, require-yieldable-reactive-read, require-yieldable-template-method, require-yieldable-insertion-write. These ask for real refactors.

The design system is the only way to style a component. The style rules — no-raw-class, no-inline-style, no-component-css, no-raw-css-value, no-free-has, style-file-boundary — are in craftRules.configs.recommended at 'error', in every file: none of them waits for a file to import @craft-ts/style.

To migrate a project in steps, turn the three binding rules off in its own ESLint config, with a TODO comment the migration removes:

js
{
  // TODO: remove once this project is migrated to @craft-ts/style.
  files: ['**/src/**/*.ts'],
  rules: {
    'craft-ts/no-raw-class': 'off',
    'craft-ts/no-inline-style': 'off',
    'craft-ts/no-component-css': 'off',
  },
},

A genuine bypass — a third-party widget that ships its own CSS, HTML rendered from markdown — stays possible, one line at a time, with a reason: // eslint-disable-next-line craft-ts/no-component-css -- vendor date picker ships its stylesheet.

The two migration rules also expose a VS Code quick fix that inserts a temporary local disable comment with the intended migration note, so you can unblock a file before doing the full refactor.

See Also#

  • Routing setup — where these rules are installed

  • CLI automation — the codemods they complement

  • Architecture rules — graph-wide constraints ESLint cannot see

  • Activating the style system — what the four style rules are guarding

←CLI automationRoute providers→

On this page

  • Scope-matched craft service inputs
  • Fixed component provider lists
  • Presets and configuration
  • Avoid flattening primitive members with craftExpose
  • Promise and transport boundaries
  • Keep resource loaders generator-based
  • Keep transport and types honest
  • Preserve primitive inference
  • Prefer primitive deep-yieldable insertions
  • Accessibility (craft-ts/a11y)
  • Why templates use blocks
  • Derived values belong to their primitive
  • Keep casts and synchronous reads out of templates
  • Form and accessibility diagnostics
  • Reactive values belong in binding callbacks
  • Pass simple yieldable callbacks directly
  • Prefer deep-yieldable forNode items
  • Yield insertion writes from generator methods
  • What generates what
  • Adopting them progressively
  • See Also

@craft-ts · MIT licensed

GitHubnpm

Search