fast-check: Property-Based Testing for JavaScript and TypeScript
fast-check is the leading property-based testing library for JavaScript and TypeScript. It brings Haskell's QuickCheck model to the JS ecosystem with full TypeScript types, a rich built-in arbitrary library, model-based testing for stateful systems, and seamless integration with Jest, Vitest, and Mocha.
Key Takeaways
fc.assert(fc.property(...)) is the core pattern. Define your arbitraries (input generators), write a predicate that must hold, and fast-check runs it hundreds of times with generated inputs.
Arbitraries compose. fc.integer, fc.string, fc.array, fc.record, fc.oneof, and fc.option are the building blocks. Combine them with .map, .filter, and .chain to model any domain object.
Model-based testing verifies stateful systems. fc.commands and the ModelRunSetup API let you generate random sequences of operations and verify your real system matches a simple model.
Async properties work natively. fc.asyncProperty accepts async predicates, making it straightforward to test async functions, Promises, and network-bound code.
fast-check is TypeScript-first. Every arbitrary is generic — fc.array<number>(fc.integer()) — so your tests are fully type-safe without extra annotation.
Why fast-check?
JavaScript testing has traditionally been dominated by example-based frameworks: you write inputs and expected outputs, run them, and repeat until coverage is high enough. fast-check changes the question from "does my function work for these inputs?" to "is there any input in this space that breaks my invariant?"
Install alongside your test runner:
npm install --save-dev fast-check
# or
yarn add --dev fast-checkfast-check has no peer dependencies and works with Jest, Vitest, Mocha, and any other runner.
The Basic Pattern
Every fast-check test follows the same structure:
import fc from 'fast-check';
// Property: addition is commutative
fc.assert(
fc.property(fc.integer(), fc.integer(), (a, b) => {
return a + b === b + a;
})
);fc.property takes one or more arbitraries followed by a predicate function. fc.assert runs it 100 times (by default) and throws if the predicate returns false or throws. In Jest or Vitest:
import fc from 'fast-check';
import { describe, it } from 'vitest';
describe('addition', () => {
it('is commutative', () => {
fc.assert(
fc.property(fc.integer(), fc.integer(), (a, b) => {
expect(a + b).toBe(b + a);
})
);
});
});Using expect inside the predicate works fine — if expect throws, fast-check catches it, records the failing inputs, and shrinks them.
Core Arbitraries
Numbers:
fc.integer() // any safe integer
fc.integer({ min: 0, max: 1000 })
fc.float()
fc.double({ noNaN: true, noDefaultInfinity: true })
fc.bigInt()
fc.bigIntN(64) // 64-bit bigintStrings:
fc.string() // arbitrary Unicode string
fc.string({ minLength: 1, maxLength: 50 })
fc.stringMatching(/^[a-z]{3,10}$/) // regex-constrained
fc.emailAddress()
fc.uuid()
fc.ipV4()
fc.ipV6()
fc.domain()
fc.webUrl()Collections:
fc.array(fc.integer())
fc.array(fc.string(), { minLength: 1, maxLength: 20 })
fc.set(fc.integer()) // unique elements
fc.tuple(fc.integer(), fc.boolean(), fc.string())
fc.dictionary(fc.string(), fc.integer()) // string keys, integer valuesObjects:
fc.record({
id: fc.uuid(),
name: fc.string({ minLength: 1 }),
age: fc.integer({ min: 18, max: 120 }),
active: fc.boolean()
})Nullable and optional:
fc.option(fc.integer()) // integer | null
fc.option(fc.string(), { nil: undefined }) // string | undefined
fc.oneof(fc.integer(), fc.string(), fc.boolean())Composing Arbitraries
.map — transform generated values:
const slugArbitrary = fc
.string({ minLength: 1, maxLength: 40 })
.map(s => s.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''));.filter — reject values that don't meet a precondition (use sparingly):
const nonEmptyString = fc.string().filter(s => s.trim().length > 0);.chain (flatMap) — generate a second arbitrary that depends on the first:
// Generate a list and a valid index into that list
const listWithIndex = fc.array(fc.integer(), { minLength: 1 }).chain(arr =>
fc.tuple(fc.constant(arr), fc.integer({ min: 0, max: arr.length - 1 }))
);
fc.assert(
fc.property(listWithIndex, ([arr, idx]) => {
expect(arr[idx]).toBeDefined();
})
);fc.letrec — build recursive arbitraries:
const { tree } = fc.letrec(tie => ({
tree: fc.oneof(
{ depthIdentifier: 'tree' },
fc.record({ type: fc.constant('leaf'), value: fc.integer() }),
fc.record({ type: fc.constant('node'), left: tie('tree'), right: tie('tree') })
)
}));Configuring Test Runs
Pass an options object to fc.assert to control how many examples are generated:
fc.assert(
fc.property(fc.integer(), fc.integer(), (a, b) => a + b === b + a),
{ numRuns: 1000, seed: 42, verbose: true }
);numRuns— how many examples to try (default: 100)seed— fix the random seed for deterministic replayverbose— print all generated examples, not just failing onesendOnFailure— stop after first failure (default: true)
For a specific failing seed found in CI, replay it locally:
fc.assert(
fc.property(fc.integer(), n => n * 2 % 2 === 0),
{ seed: 1234567890, path: "3:0" } // seed + path from failure output
);Model-Based Testing for Stateful Systems
fc.commands is fast-check's tool for testing stateful systems by generating random sequences of operations and comparing a real implementation against a simple model.
Here is a worked example — testing a Stack class:
import fc from 'fast-check';
// Real implementation under test
class Stack<T> {
private items: T[] = [];
push(item: T) { this.items.push(item); }
pop(): T | undefined { return this.items.pop(); }
peek(): T | undefined { return this.items[this.items.length - 1]; }
size(): number { return this.items.length; }
isEmpty(): boolean { return this.items.length === 0; }
}
// Simple model (trivially correct)
type StackModel = { items: number[] };
// Commands
const PushCommand = fc.integer().map(value => ({
check: (_model: StackModel) => true,
run(model: StackModel, real: Stack<number>) {
model.items.push(value);
real.push(value);
expect(real.size()).toBe(model.items.length);
},
toString: () => `push(${value})`
}));
const PopCommand = {
check: (model: StackModel) => model.items.length > 0,
run(model: StackModel, real: Stack<number>) {
const expected = model.items.pop();
const actual = real.pop();
expect(actual).toBe(expected);
},
toString: () => 'pop()'
};
const SizeCommand = {
check: (_model: StackModel) => true,
run(model: StackModel, real: Stack<number>) {
expect(real.size()).toBe(model.items.length);
expect(real.isEmpty()).toBe(model.items.length === 0);
},
toString: () => 'size()'
};
it('Stack behaves correctly for any command sequence', () => {
fc.assert(
fc.property(
fc.commands([PushCommand, fc.constant(PopCommand), fc.constant(SizeCommand)], { maxCommands: 100 }),
cmds => {
const model: StackModel = { items: [] };
const real = new Stack<number>();
fc.modelRun(() => ({ model, real }), cmds);
}
)
);
});fast-check will generate random sequences of push, pop, and size commands. When it finds a sequence that breaks an assertion, it shrinks it to the shortest sequence that still fails.
Async Properties
Many JavaScript functions are async. fast-check handles this natively with fc.asyncProperty:
import fc from 'fast-check';
// Async function under test
async function fetchUser(id: number): Promise<{ id: number; name: string }> {
// ... some async operation
}
it('fetchUser always returns an object with the correct id', async () => {
await fc.assert(
fc.asyncProperty(fc.integer({ min: 1, max: 10000 }), async (id) => {
const user = await fetchUser(id);
expect(user.id).toBe(id);
}),
{ numRuns: 50 }
);
});For testing HTTP handlers with supertest:
import request from 'supertest';
import { app } from '../app';
it('POST /orders accepts any valid order', async () => {
await fc.assert(
fc.asyncProperty(
fc.record({
productId: fc.uuid(),
quantity: fc.integer({ min: 1, max: 100 }),
currency: fc.constantFrom('USD', 'EUR', 'GBP')
}),
async (orderData) => {
const res = await request(app).post('/orders').send(orderData);
expect(res.status).toBeLessThan(500); // never a server error
expect(res.body).toHaveProperty('orderId');
}
),
{ numRuns: 50 }
);
});Integration with Jest and Vitest
fast-check works out of the box with both Jest and Vitest. No configuration needed — just import and use. For better failure messages, throw errors with descriptive messages:
fc.assert(
fc.property(fc.array(fc.integer(), { minLength: 1 }), arr => {
const sorted = [...arr].sort((a, b) => a - b);
for (let i = 0; i < sorted.length - 1; i++) {
if (sorted[i] > sorted[i + 1]) {
throw new Error(`Sort violated at index ${i}: ${sorted[i]} > ${sorted[i + 1]}`);
}
}
})
);Use fc.pre instead of .filter inside the predicate to skip invalid combinations without counting them as test runs:
fc.assert(
fc.property(fc.integer(), fc.integer(), (a, b) => {
fc.pre(b !== 0);
expect(a / b).toBe(a / b); // trivial; replace with real assertion
})
);Shrinking
fast-check automatically shrinks failing examples to their minimal form. When a test fails on [17, -3, 99, 42, 0, 5, 23], fast-check will try smaller and simpler variants until it finds the shortest list that still fails — often [42] for a bug triggered by 42.
Shrinking is built into every built-in arbitrary. Custom arbitraries created with .map and .chain inherit shrinking from their source arbitrary. For complex custom arbitraries, implement the Arbitrary class directly with a custom shrink method.
Complementing fast-check with End-to-End Testing
fast-check is ideal for testing pure functions, data transformations, and stateful in-process systems. It cannot test browser rendering, authentication flows, real network behavior, or multi-service interactions.
HelpMeTest covers that layer. It runs AI-powered end-to-end tests against your deployed application and pairs naturally with fast-check: use fast-check for fast, exhaustive property coverage at the unit and integration level; use HelpMeTest for end-to-end confidence that those properties hold when your frontend, API, and database are wired together. The combination gives you coverage at every level of the stack without duplicating effort.
Summary
fast-check brings the full power of property-based testing to JavaScript and TypeScript with an ergonomic API, first-class TypeScript support, and deep integration with the existing JS test ecosystem. Its composable arbitrary system, model-based testing commands, and async property support make it the right tool for finding edge cases in data-heavy code, parsers, state machines, and any logic that must be correct for a wide space of inputs.