Skip to main content
On this pageOverview

Oxlint Plugin

Overview

Foldkit projects use oxlint for linting and @foldkit/oxlint-plugin for rules that understand Foldkit naming and Message conventions.

Scaffolded Projects

Create Foldkit app includes .oxlintrc.json, a lint script, oxlint, and @foldkit/oxlint-plugin. A generated project enables this starter set of rules:

{
  "$schema": "./node_modules/oxlint/configuration_schema.json",
  "plugins": ["typescript"],
  "jsPlugins": [
    {
      "name": "foldkit",
      "specifier": "@foldkit/oxlint-plugin"
    }
  ],
  "categories": {
    "correctness": "off"
  },
  "rules": {
    "no-unused-vars": [
      "error",
      {
        "argsIgnorePattern": "^_",
        "varsIgnorePattern": "^_",
        "caughtErrorsIgnorePattern": "^_",
        "destructuredArrayIgnorePattern": "^_"
      }
    ],
    "typescript/no-explicit-any": "error",
    "typescript/consistent-type-assertions": [
      "error",
      {
        "assertionStyle": "never"
      }
    ],
    "foldkit/no-noop-message": "error",
    "foldkit/got-submodel-message-name": "error",
    "foldkit/message-binding-matches-tag": "error",
    "foldkit/got-prefix-requires-submodel-payload": "error",
    "foldkit/no-empty-object-tagged-call": "error",
    "foldkit/prefer-callable-message-constructor": "error",
    "foldkit/command-binding-matches-name": "error",
    "foldkit/no-module-level-mutable-state": "error"
  },
  "ignorePatterns": [
    "dist/",
    "node_modules/",
    "repos/",
    "**/*.d.ts",
    "vite.config.ts",
    "vitest.config.ts",
    "**/*.config.js",
    "**/*.config.mjs"
  ]
}

The full rule set is grouped by convention surface below. The rules enabled in the scaffold config above are the starter set; the rest are opt-in. Turn one on by adding "foldkit/<rule-name>": "error" to the rules block. Each rule covers a Foldkit-specific case that oxlint does not know about on its own.

Message Naming and Construction

foldkit/no-noop-message

Rejects catch-all Messages that make update branches and traces less meaningful. Name the event that happened instead.

import { m } from 'foldkit/message'

// ❌ Bad
const NoOp = m('NoOp')

// ✅ Good
const ClickedSave = m('ClickedSave')

foldkit/message-binding-matches-tag

Keeps a Message binding and its m() tag identical, so renames do not leave misleading traces behind.

import { m } from 'foldkit/message'

// ❌ Bad
const ClickedSave = m('ClickedSubmit')

// ✅ Good
const ClickedSubmit = m('ClickedSubmit')

foldkit/no-empty-object-tagged-call

Catches empty-object calls to no-field Message constructors. A no-field Message should be called with no arguments.

import { m } from 'foldkit/message'

const ClickedSave = m('ClickedSave')

// ❌ Bad
const badMessage = ClickedSave({})

// ✅ Good
const goodMessage = ClickedSave()

foldkit/prefer-callable-message-constructor

Prevents constructing Messages by typing or casting object literals. Use the callable Schema constructor instead.

import { Schema as S } from 'effect'
import { m } from 'foldkit/message'

const ClickedSave = m('ClickedSave')
const Message = S.Union([ClickedSave])
type Message = typeof Message.Type

// ❌ Bad
const badMessage: Message = {
  _tag: 'ClickedSave',
}

// ✅ Good
const goodMessage = ClickedSave()

Command Shape

foldkit/command-binding-matches-name

Keeps a Command binding name in sync with the name passed to Command.define.

import { Effect } from 'effect'
import { Command } from 'foldkit'
import { m } from 'foldkit/message'

const CompletedFetchUser = m('CompletedFetchUser')

// ❌ Bad
const SaveUser = Command.define(
  'FetchUser',
  CompletedFetchUser,
)(Effect.succeed(CompletedFetchUser()))

// ✅ Good
const FetchUser = Command.define(
  'FetchUser',
  CompletedFetchUser,
)(Effect.succeed(CompletedFetchUser()))

foldkit/command-define-pascal-const

Requires the const holding a Command.define result to be a non-empty PascalCase identifier that matches the Command name.

import { Command } from 'foldkit'

// ❌ Bad
// A Command binding should be PascalCase, like the Command name it holds.
const fetchWeather = Command.define(
  'FetchWeather',
  SucceededFetchWeather,
)(fetchWeatherEffect)

// ✅ Good
const FetchWeather = Command.define(
  'FetchWeather',
  SucceededFetchWeather,
)(fetchWeatherEffect)

foldkit/no-hand-rolled-command-struct

Rejects Command structs assembled by hand. Command.define attaches the identity, args, and tracing metadata a plain object literal skips.

import { Command } from 'foldkit'

// ❌ Bad
// Hand-rolling the Command struct skips the identity, args, and tracing
// metadata that Command.define attaches.
const SaveDraft = {
  name: 'SaveDraft',
  effect: saveDraftEffect,
}

// ✅ Good
const FetchWeather = Command.define(
  'FetchWeather',
  SucceededFetchWeather,
)(fetchWeatherEffect)

Model Updates

foldkit/no-spread-in-evo

Rejects object spreads inside an evo updater. Evolve nested fields with a nested evo instead.

import { evo } from 'foldkit/struct'

// ❌ Bad
// Spreading a nested field inside evo defeats the point of evo.
const badUpdate = (model: Model) =>
  evo(model, {
    user: () => ({ ...model.user, name: 'Ada' }),
  })

// ✅ Good
// Evolve the nested field with a nested evo.
const goodUpdate = (model: Model) =>
  evo(model, {
    user: user => evo(user, { name: () => 'Ada' }),
  })

Routing

foldkit/no-hardcoded-route-strings

Rejects hardcoded path and URL strings passed to link and navigation helpers. Build them from the Route module so they stay in sync with the routes.

import { html } from 'foldkit/html'

import { tasksRouter } from '../route'

const h = html<Message>()

// ❌ Bad
// A hardcoded path rots when the route changes and bypasses the Route module.
const badLink = h.a([h.Href('/tasks')], [text('Tasks')])

// ✅ Good
// Build the href from the Router so it stays in sync with the route.
const goodLink = h.a([h.Href(tasksRouter())], [text('Tasks')])

View Keying and Accessibility

foldkit/no-array-index-view-keys

Rejects the array index as a view key. Key by a stable Model identifier, or reordering the list patches the wrong rows.

import { html } from 'foldkit/html'

const h = html<Message>()

// ❌ Bad
// The array index is not a stable identity: reordering patches the wrong rows.
const badList = (tasks: ReadonlyArray<Task>) =>
  h.ul(
    [],
    tasks.map((task, index) => h.keyed('li')(index, [], [text(task.title)])),
  )

// ✅ Good
// Key by a stable Model identifier.
const goodList = (tasks: ReadonlyArray<Task>) =>
  h.ul(
    [],
    tasks.map(task => h.keyed('li')(task.id, [], [text(task.title)])),
  )

foldkit/keyed-required-for-mapped-rows

Requires an identity-bearing mapped row element to be wrapped in keyed, so the runtime patches the right rows when the list reorders or shrinks.

import { html } from 'foldkit/html'

const h = html<Message>()

// ❌ Bad
// The row carries the task's identity (its id), so leaving it unkeyed lets the
// runtime patch the wrong row when the list reorders or shrinks.
const badList = (tasks: ReadonlyArray<Task>) =>
  h.ul(
    [],
    tasks.map(task =>
      h.li([h.OnClick(ClickedTask({ id: task.id }))], [text(task.title)]),
    ),
  )

// ✅ Good
const goodList = (tasks: ReadonlyArray<Task>) =>
  h.ul(
    [],
    tasks.map(task =>
      h.keyed('li')(
        task.id,
        [h.OnClick(ClickedTask({ id: task.id }))],
        [text(task.title)],
      ),
    ),
  )

Requires target="_blank" links to carry a rel with noopener or noreferrer.

import { html } from 'foldkit/html'

const h = html<Message>()

// ❌ Bad
// target="_blank" without rel leaves the new tab able to reach window.opener.
const badLink = h.a(
  [h.Href('https://example.com'), h.Target('_blank')],
  [text('Docs')],
)

// ✅ Good
const goodLink = h.a(
  [
    h.Href('https://example.com'),
    h.Target('_blank'),
    h.Rel('noopener noreferrer'),
  ],
  [text('Docs')],
)

foldkit/no-raw-dom-event-attributes

Rejects raw DOM event attributes. Use the typed event helpers so handlers dispatch Messages through the runtime.

import { html } from 'foldkit/html'

const h = html<Message>()

// ❌ Bad
// A raw DOM event attribute escapes the typed handlers and the Message flow.
const badButton = h.button(
  [h.Attribute('onclick', 'location.reload()')],
  [text('Reload')],
)

// ✅ Good
// Dispatch a Message through the typed event helper.
const goodButton = h.button([h.OnClick(ClickedReload())], [text('Reload')])

Purity Boundaries

foldkit/no-module-level-mutable-state

Rejects module-level let and var bindings, which hold state outside the Model. Move the data into the Model, or scope a live handle to a lifecycle primitive like Mount or ManagedResource.

import { Schema as S } from 'effect'

// ❌ Bad
let requestCount = 0

// ✅ Good
export const Model = S.Struct({
  requestCount: S.Number,
})

export type Model = typeof Model.Type

foldkit/no-disabling-dev-guardrails

Flags turning off the freezeModel or slow dev guardrails. Fix the mutation or slow phase they caught instead of silencing the feedback.

import { Runtime } from 'foldkit'

// ❌ Bad
// Turning off freezeModel silences the dev warning instead of fixing the
// mutation it caught.
const badApp = Runtime.makeApplication({
  Model,
  init,
  update,
  view,
  freezeModel: false,
})

// ✅ Good
// Leave the guardrail on and fix the in-place mutation it flags.
const goodApp = Runtime.makeApplication({ Model, init, update, view })

Submodel Wiring

foldkit/got-submodel-message-name

Requires wrapper Messages around Submodel Messages to use the Got*Message convention.

import { m } from 'foldkit/message'

import * as Child from './child'

// ❌ Bad
const ChildChanged = m('ChildChanged', {
  message: Child.Message,
})

// ✅ Good
const GotChildMessage = m('GotChildMessage', {
  message: Child.Message,
})

foldkit/got-prefix-requires-submodel-payload

Reserves the Got* prefix for Submodel wrappers. Any Got-prefixed Message must include a child Message payload named message.

import { Schema as S } from 'effect'
import { m } from 'foldkit/message'

import * as Child from './child'

{
  // ❌ Bad: Got is reserved for Submodel wrappers.
  const GotWeather = m('GotWeather', {
    temperature: S.Number,
  })
}

{
  // ✅ Good: use a name that does not start with Got for Command results.
  const ReceivedWeather = m('ReceivedWeather', {
    temperature: S.Number,
  })
}

{
  // ❌ Bad: Got-prefixed wrappers must carry child Messages.
  const GotChildMessage = m('GotChildMessage', {
    id: S.String,
  })
}

{
  // ✅ Good: Got wraps a child Message.
  const GotChildMessage = m('GotChildMessage', {
    id: S.String,
    message: Child.Message,
  })
}

foldkit/wrap-child-output-in-got-message

Requires child Command and Subscription output to be wrapped through a Got*Message constructor, preserving the one-wrap-per-level Submodel convention.

import { Command } from 'foldkit'

// ❌ Bad
// The mapper wraps child output in a plain parent Message, not a Got*Message,
// so this Submodel level never records the wrap.
const badCommands = Command.mapMessages(childCommands, message =>
  ForwardedChildMessage({ message }),
)

// ✅ Good
const goodCommands = Command.mapMessages(childCommands, message =>
  GotChildMessage({ message }),
)

foldkit/got-wrapper-carries-only-routing

Keeps a Got wrapper payload to the child Message plus routing keys: message, id, or keys ending in Id.

import { Schema as S } from 'effect'
import { m } from 'foldkit/message'

// ❌ Bad
// A Got wrapper carries the child Message plus routing context only. Extra
// payload like timestamp belongs on the child Message or a parent Message.
const GotSettingsMessage = m('GotSettingsMessage', {
  message: Settings.Message,
  timestamp: S.Number,
})

// ✅ Good
// message plus routing keys (id, or keys ending in Id) only.
const GotCounterMessage = m('GotCounterMessage', {
  id: S.String,
  message: Counter.Message,
})

foldkit/no-child-message-construction-in-root

Rejects constructing a child Message variant from outside the child. Call a child-exported helper and route its output through the wrapper.

// ❌ Bad
// The root reaches into the child Message namespace to build a child Message.
const badRouting = () =>
  GotChildMessage({ message: Child.Message.ClickedSave() })

// ✅ Good
// The child exports a helper; the root routes its output through the wrapper.
const goodRouting = () => GotChildMessage({ message: Child.clickedSave() })

foldkit/selection-submodel-factory-at-module-scope

Requires selection component factories, such as Combobox, Listbox, Menu, and Tabs, to be created at module scope so their identity stays stable across renders.

import { Listbox } from '@foldkit/ui'

const sortListbox = Listbox.create()

// ❌ Bad
// Re-creating the factory on each update gives it a fresh identity, so its
// internal selection state never persists.
const badUpdate = (model: Model, message: Message) => {
  const listbox = Listbox.create()
  return listbox.update(model.sort, message)
}

// ✅ Good
// Reuse the module-scope factory.
const goodUpdate = (model: Model, message: Message) =>
  sortListbox.update(model.sort, message)

Lifecycle Handles

foldkit/mount-factory-must-use-element

Requires a Mount factory to read or write its element. If it never touches the element, the cause was misidentified and Mount is the wrong primitive.

import { Effect } from 'effect'
import { Mount } from 'foldkit'

// ❌ Bad
// The factory never reads its element, so Mount is the wrong primitive here.
const MountAnalytics = Mount.define(
  'MountAnalytics',
  {},
  CompletedMountAnalytics,
)(() => () => Effect.sync(() => startAnalytics()))

// ✅ Good
// The factory reads its element to wire the observer.
const MountResize = Mount.define(
  'MountResize',
  {},
  CompletedMountResize,
)(() => element => Effect.sync(() => resizeObserver.observe(element)))

foldkit/no-duplicate-onmount-per-element

Rejects two OnMount handlers on one element, where the second silently overwrites the first.

import { html } from 'foldkit/html'

const h = html<Message>()

// ❌ Bad
// Two OnMount handlers on one element: the second overwrites the first.
const badPanel = h.div(
  [h.OnMount(AnchorPopover()), h.OnMount(SyncScroll())],
  [],
)

// ✅ Good
// One OnMount per element; combine the work into a single Mount if needed.
const goodPanel = h.div([h.OnMount(AnchorPopover())], [])

DOM and UI Helpers

foldkit/lazy-view-stable-references

Requires lazy view slots to be declared at module scope so their references stay stable and the memoization actually hits its cache.

import { createLazy } from 'foldkit/html'

// ❌ Bad
// Creating the lazy slot inside the view gives it a new identity every render,
// so the memoized view never hits its cache.
const badView = (model: Model) => {
  const lazyHeader = createLazy()
  return lazyHeader(renderHeader, [model.title])
}

// ✅ Good
// Declare the lazy slot once at module scope.
const lazyHeader = createLazy()
const goodView = (model: Model) => lazyHeader(renderHeader, [model.title])

Stay in the update loop.

New releases, patterns, and the occasional deep dive.


Built with Foldkit.

© 2026 Devin Jameson