Property-Based Testing in TypeScript with Fast-Check
How property-based testing finds the edge cases your unit tests miss: a practical guide to fast-check for generating inputs and verifying invariants.

Traditional unit tests verify behavior for specific inputs you choose. Property-based testing flips this — you describe the properties your code must satisfy, and the framework generates hundreds or thousands of random inputs to find violations. When it finds one, it shrinks the input to the smallest reproducible case.
The result: your tests find edge cases you never thought to write. Off-by-one errors, empty inputs, Unicode edge cases, integer overflow, and combinations of boundary values that would require thousands of hand-written test cases to cover.
Why Example-Based Tests Miss Bugs
Consider a function that sorts an array. You might write three example-based tests: empty array, already sorted, and reverse sorted. That covers three cases out of infinite possibilities.
// ❌ Example-based tests — cover only cases you think of
import { describe, it, expect } from 'vitest';
import { customSort } from './sort';
describe('customSort', () => {
it('handles empty arrays', () => {
expect(customSort([])).toEqual([]);
});
it('sorts already sorted arrays', () => {
expect(customSort([1, 2, 3])).toEqual([1, 2, 3]);
});
it('sorts reverse arrays', () => {
expect(customSort([3, 2, 1])).toEqual([1, 2, 3]);
});
});
// These pass, but what about [NaN, -0, Infinity]?
// What about arrays with 10,000 duplicate elements?
// What about negative numbers mixed with zero?// ✅ Property-based tests — describe what MUST be true for ALL inputs
import { describe, it } from 'vitest';
import * as fc from 'fast-check';
import { customSort } from './sort';
describe('customSort properties', () => {
it('output length equals input length', () => {
fc.assert(
fc.property(fc.array(fc.integer()), (arr) => {
const sorted = customSort(arr);
return sorted.length === arr.length;
})
);
});
it('output is sorted in ascending order', () => {
fc.assert(
fc.property(fc.array(fc.integer()), (arr) => {
const sorted = customSort(arr);
for (let i = 1; i < sorted.length; i++) {
if (sorted[i] < sorted[i - 1]) return false;
}
return true;
})
);
});
it('output contains the same elements as input', () => {
fc.assert(
fc.property(fc.array(fc.integer()), (arr) => {
const sorted = customSort(arr);
const inputCounts = countElements(arr);
const outputCounts = countElements(sorted);
return mapsEqual(inputCounts, outputCounts);
})
);
});
});Three properties — length preservation, ordering, and element preservation — cover the complete behavioral contract of a sort function for any integer array, not just the three examples you happened to write.
Getting Started with Fast-Check
Fast-check is the most popular property-based testing library for TypeScript. It integrates with any test runner — Vitest, Jest, Mocha.
npm install -D fast-checkimport * as fc from 'fast-check';
// Basic arbitraries — generators for random values
fc.integer(); // Random integers
fc.string(); // Random strings (including Unicode)
fc.boolean(); // true or false
fc.float(); // Random floating-point numbers
fc.array(fc.integer()); // Arrays of random integers
fc.date(); // Random Date objects
// Constrained arbitraries
fc.integer({ min: 1, max: 100 }); // Integers between 1 and 100
fc.string({ minLength: 1, maxLength: 50 }); // Non-empty strings up to 50 chars
fc.array(fc.integer(), { minLength: 1 }); // Non-empty integer arrays
// Composing custom arbitraries for domain objects
const userArbitrary = fc.record({
id: fc.uuid(),
name: fc.string({ minLength: 1, maxLength: 100 }),
email: fc.emailAddress(),
age: fc.integer({ min: 18, max: 120 }),
isActive: fc.boolean(),
});
// Generates random User objects with valid structureTesting Real Business Logic
Property-based testing shines for business logic with complex invariants. Here is an example testing a discount calculator:
interface CartItem {
productId: string;
price: number;
quantity: number;
}
interface DiscountResult {
subtotal: number;
discount: number;
total: number;
}
function calculateDiscount(items: CartItem[], couponPercent: number): DiscountResult {
const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
const discount = subtotal * (couponPercent / 100);
const total = subtotal - discount;
return { subtotal, discount, total };
}
// Property-based tests for the discount calculator
describe('calculateDiscount', () => {
const cartItemArb = fc.record({
productId: fc.uuid(),
price: fc.float({ min: 0.01, max: 10000, noNaN: true }),
quantity: fc.integer({ min: 1, max: 100 }),
});
const cartArb = fc.array(cartItemArb, { minLength: 1, maxLength: 20 });
const couponArb = fc.float({ min: 0, max: 100, noNaN: true });
it('total is never negative', () => {
fc.assert(
fc.property(cartArb, couponArb, (items, coupon) => {
const result = calculateDiscount(items, coupon);
return result.total >= 0;
})
);
});
it('total + discount equals subtotal', () => {
fc.assert(
fc.property(cartArb, couponArb, (items, coupon) => {
const result = calculateDiscount(items, coupon);
return Math.abs(result.total + result.discount - result.subtotal) < 0.01;
})
);
});
it('zero coupon means no discount', () => {
fc.assert(
fc.property(cartArb, (items) => {
const result = calculateDiscount(items, 0);
return result.discount === 0 && result.total === result.subtotal;
})
);
});
it('100% coupon means zero total', () => {
fc.assert(
fc.property(cartArb, (items) => {
const result = calculateDiscount(items, 100);
return Math.abs(result.total) < 0.01;
})
);
});
});Shrinking: Finding Minimal Failures
When fast-check finds a failing input, it automatically shrinks it to the smallest input that still triggers the failure. This makes debugging dramatically easier.
// Imagine a bug in a string processing function
function processTitle(input: string): string {
// Bug: crashes on strings containing null characters
return input.trim().toLowerCase().replace(/\s+/g, '-');
}
// Fast-check might initially find failure with:
// "a\u0000bCdEf\u0000gh..." (a long, complex string)
// After shrinking, it reports the minimal case:
// "\u0000" (single null character)
// The shrunk case makes the bug obvious:
// .trim() doesn't handle null characters as expected
// You can configure shrinking behavior:
fc.assert(
fc.property(fc.string(), (input) => {
const result = processTitle(input);
return typeof result === 'string' && result.length >= 0;
}),
{
numRuns: 1000, // Run 1000 random inputs
seed: 42, // Reproducible randomness
endOnFailure: true // Stop at first failure
}
);// ❌ No seed — failures may not reproduce
fc.assert(fc.property(fc.string(), validator));
// If this fails, you might not be able to reproduce it
// ✅ Use seeds for reproducibility — fast-check logs the seed on failure
fc.assert(
fc.property(fc.string(), validator),
{ seed: 12345 }
);
// Failure output includes: "Seed: 12345"
// Re-run with same seed → same sequence of inputs → same failureCombining with Example-Based Tests
Property-based tests do not replace example-based tests — they complement them. Use examples for documentation and edge cases you know about. Use properties for discovering edge cases you do not.
describe('parseEmail', () => {
// Example-based: documents known behavior and specific edge cases
it('parses standard email', () => {
expect(parseEmail('user@example.com')).toEqual({
local: 'user',
domain: 'example.com',
});
});
it('rejects email without @', () => {
expect(parseEmail('invalid')).toBeNull();
});
// Property-based: finds unknown edge cases
it('round-trips valid emails', () => {
fc.assert(
fc.property(fc.emailAddress(), (email) => {
const parsed = parseEmail(email);
if (parsed === null) return false; // Valid email should parse
return `${parsed.local}@${parsed.domain}` === email.toLowerCase();
})
);
});
it('never throws on any string input', () => {
fc.assert(
fc.property(fc.string(), (input) => {
// Should return result or null, never throw
const result = parseEmail(input);
return result === null || (typeof result.local === 'string' && typeof result.domain === 'string');
})
);
});
});Key Takeaways
- Property-based tests describe invariants — what must be true for all inputs, not just the inputs you chose
- Fast-check generates hundreds of random inputs and automatically shrinks failures to minimal reproducible cases
- Core properties to test: idempotency, round-tripping, invariant preservation, commutativity, and "never throws"
- Use constrained arbitraries to generate domain-valid objects —
fc.record,fc.integer({ min, max }),fc.emailAddress() - Combine with example tests — examples document known behavior, properties discover unknown edge cases
- Set seeds for reproducibility — fast-check logs the seed on failure so you can replay the exact sequence


