On this pageOverview
Oxlint Plugin
Foldkit projects use oxlint for linting and @foldkit/oxlint-plugin for rules that understand Foldkit naming and Message conventions.
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.
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')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')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()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()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', {
messages: [CompletedFetchUser],
execute: Effect.succeed(CompletedFetchUser()),
})
// ✅ Good
const FetchUser = Command.define('FetchUser', {
messages: [CompletedFetchUser],
execute: Effect.succeed(CompletedFetchUser()),
})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', {
messages: [SucceededFetchWeather],
execute: fetchWeatherEffect,
})
// ✅ Good
const FetchWeather = Command.define('FetchWeather', {
messages: [SucceededFetchWeather],
execute: fetchWeatherEffect,
})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', {
messages: [SucceededFetchWeather],
execute: fetchWeatherEffect,
})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' }),
})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 type { HtmlBuilder } from 'foldkit/html'
import { tasksRouter } from '../route'
// ❌ Bad
// A hardcoded path rots when the route changes and bypasses the Route module.
const badLink = (h: HtmlBuilder<Message>) => h.a([h.Href('/tasks')], ['Tasks'])
// ✅ Good
// Build the href from the Router so it stays in sync with the route.
const goodLink = (h: HtmlBuilder<Message>) =>
h.a([h.Href(tasksRouter())], ['Tasks'])Rejects the array index as a view key. Key by a stable Model identifier, or reordering the list patches the wrong rows.
import type { HtmlBuilder } from 'foldkit/html'
// ❌ Bad
// The array index is not a stable identity: reordering patches the wrong rows.
const badList = (tasks: ReadonlyArray<Task>, h: HtmlBuilder<Message>) =>
h.ul(
[],
tasks.map((task, index) => h.keyed('li')(index, [], [task.title])),
)
// ✅ Good
// Key by a stable Model identifier.
const goodList = (tasks: ReadonlyArray<Task>, h: HtmlBuilder<Message>) =>
h.ul(
[],
tasks.map(task => h.keyed('li')(task.id, [], [task.title])),
)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 type { HtmlBuilder } from 'foldkit/html'
// ❌ 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: HtmlBuilder<Message>) =>
h.ul(
[],
tasks.map(task =>
h.li([h.OnClick(ClickedTask({ id: task.id }))], [task.title]),
),
)
// ✅ Good
const goodList = (tasks: ReadonlyArray<Task>, h: HtmlBuilder<Message>) =>
h.ul(
[],
tasks.map(task =>
h.keyed('li')(
task.id,
[h.OnClick(ClickedTask({ id: task.id }))],
[task.title],
),
),
)Requires target="_blank" links to carry a rel with noopener or noreferrer.
import type { HtmlBuilder } from 'foldkit/html'
// ❌ Bad
// target="_blank" without rel leaves the new tab able to reach window.opener.
const badLink = (h: HtmlBuilder<Message>) =>
h.a([h.Href('https://example.com'), h.Target('_blank')], ['Docs'])
// ✅ Good
const goodLink = (h: HtmlBuilder<Message>) =>
h.a(
[
h.Href('https://example.com'),
h.Target('_blank'),
h.Rel('noopener noreferrer'),
],
['Docs'],
)Rejects raw DOM event attributes. Use the typed event helpers so handlers dispatch Messages through the runtime.
import type { HtmlBuilder } from 'foldkit/html'
// ❌ Bad
// A raw DOM event attribute escapes the typed handlers and the Message flow.
const badButton = (h: HtmlBuilder<Message>) =>
h.button([h.Attribute('onclick', 'location.reload()')], ['Reload'])
// ✅ Good
// Dispatch a Message through the typed event helper.
const goodButton = (h: HtmlBuilder<Message>) =>
h.button([h.OnClick(ClickedReload())], ['Reload'])Catches an inline empty array in the children slot, on element builders and on keyed. The argument is optional, so an element with no children omits it. The shorter form needs the Foldkit release that made children optional, so bump foldkit alongside the plugin.
import type { HtmlBuilder } from 'foldkit/html'
// ❌ Bad
// The trailing [] is what the builder already defaults to, so it carries nothing.
const badDivider = (h: HtmlBuilder<Message>) =>
h.div([h.Class('h-px bg-gray-200')], [])
const badRows = (tags: ReadonlyArray<Tag>, h: HtmlBuilder<Message>) =>
h.ul(
[],
tags.map(tag => h.keyed('li')(tag.id, [h.Class(tag.className)], [])),
)
// ✅ Good
// Omit the argument. Attributes stay required, so h.div([]) is an element with neither.
const goodDivider = (h: HtmlBuilder<Message>) =>
h.div([h.Class('h-px bg-gray-200')])
const goodRows = (tags: ReadonlyArray<Tag>, h: HtmlBuilder<Message>) =>
h.ul(
[],
tags.map(tag => h.keyed('li')(tag.id, [h.Class(tag.className)])),
)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.TypeFlags 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 })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,
})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,
})
}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 }),
)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,
})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() })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)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)))Rejects two OnMount handlers on one element, where the second silently overwrites the first.
import type { HtmlBuilder } from 'foldkit/html'
// ❌ Bad
// Two OnMount handlers on one element: the second overwrites the first.
const badPanel = (h: HtmlBuilder<Message>) =>
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: HtmlBuilder<Message>) =>
h.div([h.OnMount(AnchorPopover())])Requires lazy view slots to be declared at module scope so their references stay stable and the memoization actually hits its cache.
import { type HtmlBuilder, 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, h: HtmlBuilder<Message>) => {
const lazyHeader = createLazy()
return lazyHeader(renderHeader, [model.title, h])
}
// ✅ Good
// Declare the lazy slot once at module scope.
const lazyHeader = createLazy()
const goodView = (model: Model, h: HtmlBuilder<Message>) =>
lazyHeader(renderHeader, [model.title, h])