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',
CompletedFetchUser,
)(Effect.succeed(CompletedFetchUser()))
// ✅ Good
const FetchUser = Command.define(
'FetchUser',
CompletedFetchUser,
)(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',
SucceededFetchWeather,
)(fetchWeatherEffect)
// ✅ Good
const FetchWeather = Command.define(
'FetchWeather',
SucceededFetchWeather,
)(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',
SucceededFetchWeather,
)(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 { 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')])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)])),
)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')],
)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')])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 { 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())], [])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])