Type-safe ReScript bindings for Vitest.
The bindings are faithful: matchers are side-effecting and throw on failure,
exactly like Vitest itself. The expect(value) wrapper carries the type of the
value under test, so matchers such as toBe stay honest at compile time.
- ✅
describe/test/it(+.only/.skip/.todo/.each/.concurrent/.sequential/.shuffle/.skipIf/.runIf/.fails/.for) - ✅ Lifecycle hooks (
beforeEach/afterEach/beforeAll/afterAll/aroundEach/aroundAll/onTestFailed/onTestFinished, sync & async) - ✅ The full
expectmatcher set (equality, numbers, strings, collections, objects, type/predicate, exceptions, snapshots — inline and file, mock call/return/resolve matchers) - ✅ Asymmetric matchers and negations (
Expect.anything/arrayContaining/objectContaining/ … andExpect.Not.*), plus assertion guards (assertions/hasAssertions/soft/poll) - ✅ Negation (
not_) and async assertions (resolves/rejects) - ✅
Vi— mock functions, spies (incl. getter/setter), module mocking, global/env stubs, and fake timers (sync & async) withwaitFor/waitUntil - ✅
VitestConfig— minimalvitest/configbindings (defineConfig/mergeConfig/defineProject+ the commontestfields) - ✅ Closed-set arguments are polymorphic variants, rejecting invalid values at compile time (
toBeTypeOf(#string),spyOnAccessor(…, #get),coverage.provider: #v8)
Why another binding?
rescript-vitest (cometkim) and
@greenfinity/rescript-vitest
both target Vitest 2/3 and pin the dependency tree to Vite 5. Vitest 4.1+
requires vite/module-runner (Vite 6+), so those bindings block the upgrade.
This package targets Vitest 4 (Vite 6/7) directly.
Requirements
| Tool | Version |
|---|---|
| ReScript | ^12.0.0 |
| Vitest | ^4.0.0 |
| Vite | ^6 or ^7 (Vitest 4 peer) |
Install
pnpm add -D @nagatatz/rescript-vitest vitest vite
Add the package to your rescript.json dependencies so ReScript compiles the
bindings:
{
"dependencies": ["@nagatatz/rescript-vitest"]
}Point Vitest at the compiled test files (*.res.js):
// vitest.config.js import { defineConfig } from 'vitest/config' export default defineConfig({ test: { include: ['__tests__/**/*_test.res.js'], }, })
Usage
open Vitest describe("Math", () => { test("adds", () => { expect(1 + 1)->toBe(2) }) test("negation", () => { expect([1, 2])->not_->toContain(3) }) testAsync("works with promises", async () => { await expect(Promise.resolve(42))->resolves->Async.toBe(42) }) })
Mocks, spies and timers
open Vitest test("counts calls", () => { let mock = Vi.fn1() let fn = mock->Vi.MockFn.asFn // use the mock where a function is expected fn("a")->ignore mock->Vi.MockFn.asAssertion->toHaveBeenCalledOnce }) test("fake timers", () => { Vi.useFakeTimers() // ... schedule a timer ... Vi.advanceTimersByTime(1000) Vi.useRealTimers() })
API cheat sheet
Test structure (Vitest)
describe, describeAsync, describeOnly, describeSkip, describeEach,
describeTodo, describeConcurrent, describeSequential, describeShuffle,
describeSkipIf, describeRunIf, describeFor,
test, testAsync, testOnly, testSkip, testTodo, testConcurrent,
testEach, testFor, testSkipIf, testSkipIfAsync, testRunIf, testRunIfAsync,
testFails, testFailsAsync, testSequential, testSequentialAsync,
it, itAsync, itOnly, itSkip, itTodo, itConcurrent, itEach, itFails,
itSequential, itSkipIf, itRunIf.
Lifecycle
beforeAll, afterAll, beforeEach, afterEach, the suite/test wrappers aroundAll, aroundEach (each receives a runSuite / runTest thunk to await), plus the per-test hooks onTestFailed, onTestFinished (the before*/after*/onTest* hooks each have an …Async variant).
expect matchers
- Equality:
toBe,toEqual,toStrictEqual - Truthiness:
toBeTruthy,toBeFalsy,toBeNull,toBeUndefined,toBeDefined,toBeNaN - Numbers:
toBeGreaterThan,toBeGreaterThanOrEqual,toBeLessThan,toBeLessThanOrEqual,toBeCloseTo,toBeCloseToWithDigits - Strings:
toMatch,toMatchRegExp,toContainString - Collections:
toContain,toContainEqual,toHaveLength - Objects:
toMatchObject,toHaveProperty,toHavePropertyValue - Type & predicate:
toBeTypeOf,toBeInstanceOf,toBeOneOf,toSatisfy - Exceptions:
toThrow,toThrowWithMessage,toThrowRegExp - Snapshots:
toMatchSnapshot,toMatchSnapshotWithName,toMatchInlineSnapshot,toMatchFileSnapshot,toThrowErrorMatchingSnapshot,toThrowErrorMatchingInlineSnapshot - Mocks (calls):
toHaveBeenCalled,toHaveBeenCalledOnce,toHaveBeenCalledTimes,toHaveBeenCalledWith(+…With2),toHaveBeenLastCalledWith(+…With2),toHaveBeenNthCalledWith(+…With2),toHaveBeenCalledExactlyOnceWith(+…With2) - Mocks (returns):
toHaveReturned,toHaveReturnedTimes,toHaveReturnedWith,toHaveLastReturnedWith,toHaveNthReturnedWith - Mocks (resolves):
toHaveResolved,toHaveResolvedTimes,toHaveResolvedWith,toHaveLastResolvedWith,toHaveNthResolvedWith - Asymmetric (
Expectmodule):anything,any,arrayContaining,objectContaining,stringContaining,stringMatching(+…RegExp),closeTo(+…WithPrecision) — embed in the expected position oftoEqual/toMatchObject/toHaveBeenCalledWith; negated forms live inExpect.Not - Guards & special (
Expectmodule):assertions,hasAssertions,soft,poll,unreachable(+…WithMessage) - Modifiers:
not_,resolves/rejects(+ theAsyncmatcher module)
Not yet bound
test.extend (fixtures) and expect.extend (custom matchers) are intentionally
left unbound: their JavaScript shapes — a fixtures object injected via use
callbacks, and matchers added dynamically onto every assertion — cannot be
expressed faithfully or type-safely in ReScript without per-matcher manual
bindings. Use ReScript helper functions instead of fixtures, and a dedicated
@send external if you must call a project-specific custom matcher.
Vi
- Create:
fn,fnWith,fn0,fn1,fn2,spyOn,spyOnGetter,spyOnSetter MockFn:asFn,asAssertion,calls,results,mockClear,mockReset,mockRestore,mockImplementation,mockImplementationOnce,mockReturnValue,mockReturnValueOnce,mockResolvedValue,mockResolvedValueOnce,mockRejectedValue,mockRejectedValueOnce,mockReturnThis,getMockName,mockName,getMockImplementation,withImplementation- Inspection / hoisting:
mocked,isMockFunction,hoisted - Modules:
mock,mockWithFactory,unmock,doMock,doUnmock,resetModules,importActual,importMock,mockObject,dynamicImportSettled - Global / env stubs:
stubGlobal,stubEnv,unstubAllGlobals,unstubAllEnvs - Global state:
clearAllMocks,resetAllMocks,restoreAllMocks - Timers:
useFakeTimers(+useFakeTimersWithforFakeTimerInstallOpts),useRealTimers,runAllTimers,runAllTicks,runOnlyPendingTimers,advanceTimersByTime,advanceTimersToNextTimer,setSystemTime(+…Ms),clearAllTimers - Async timers:
advanceTimersByTimeAsync,runAllTimersAsync,runOnlyPendingTimersAsync,advanceTimersToNextTimerAsync,advanceTimersToNextFrame - Timer inspection:
isFakeTimers,getTimerCount,getMockedSystemTime,getRealSystemTime,setTimerTickMode(+…WithInterval) - Waiting:
waitFor,waitUntil(each with a…Withvariant taking{interval?, timeout?})
VitestConfig (vitest/config)
Minimal config-side bindings: defineConfig, defineConfigFn (function form
receiving {mode, command}), mergeConfig, defineProject. Configs are typed
with optional-record types covering the common test fields — globals,
environment, include_, exclude, setupFiles, coverage, pool,
testTimeout, hookTimeout, reporters, watch, projects — and coverage
(provider, enabled, reporter, include_, exclude). (include is a
ReScript keyword, so the field is named include_ and maps to JS "include".)
coverage.provider is a polymorphic variant (#istanbul / #v8 / #custom);
pool and environment stay string because Vitest treats them as extensible
unions, so custom pools / environment packages remain expressible.
Scope (intentional): Vite-level config (plugins, resolve, server,
build) and the long tail of test options are not bound — write those in a
plain JS config file. Config objects are write-once data with a large, churning
surface, so full coverage is not worth the maintenance cost.
ReScript does not emit export default, so wire the typed config into the real
vitest.config through a thin JS shim:
// MyConfig.res let config = VitestConfig.defineConfig({ test: {globals: true, environment: "node", include_: ["__tests__/**/*_test.res.js"]}, })
// vitest.config.js import { config } from "./MyConfig.res.js" export default config
Development
pnpm install pnpm build # compile ReScript bindings + tests pnpm test # run the dogfood test suite under Vitest 4 pnpm format # format hand-written JS/JSON with oxfmt pnpm lint # lint hand-written JS with oxlint
Non-ReScript hand-written files (vitest.config.js, JSON configs) are formatted
with oxfmt and linted with oxlint; generated
*.res.js output is excluded via .gitignore. A pre-commit hook (activated by
the prepare script setting core.hooksPath) runs format:check + lint.
Binding correctness is verified by compilation (the bindings type-check) and
the dogfood test suite (each binding is called against a real Vitest). Code
coverage is not used as a gate: ReScript externals are erased at compile time
and leave almost no instrumentable code, so statement/line coverage cannot detect
an untested binding. pnpm test:coverage remains available for local inspection.