npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

rapidfuzz-js

v0.12.0

Published

Fast fuzzy string matching for Node.js and the browser

Readme

rapidfuzz-js

CI coverage npm license

Fast fuzzy matching for JavaScript and TypeScript, powered by the algorithms of RapidFuzz.

  • Node.js 22+, browsers, and edge runtimes
  • ESM with strict TypeScript declarations, TypeScript 5.4+
  • No runtime dependencies
  • Tree-shakeable algorithm subpaths

This file is the tour. The documentation site is the reference: guides, per-algorithm pages, the error reference, and an API section generated from the source.

Install

npm install rapidfuzz-js

Quick start

Comparing two values needs no setup — metrics are plain functions:

import { ratio } from 'rapidfuzz-js/fuzz'

ratio('this is a test', 'this is a test!') // 96.55…

The rest of the API builds on one composition model:

Metric → Scorer → Matcher

Metrics come from algorithm subpaths; orchestration comes from the package root:

import { createMatcher, createScorer, normalizeText } from 'rapidfuzz-js'
import { tokenSortRatio } from 'rapidfuzz-js/fuzz'

const scorer = createScorer(tokenSortRatio)

const products = [
  { title: 'Wireless mechanical keyboard' },
  { title: 'Compact wireless mouse' },
]

const matcher = createMatcher(products, {
  scorer,
  getText: (product) => product.title,
  normalize: normalizeText,
})

matcher.best('mechanical keybord', { threshold: 70 })
// { item: { title: 'Wireless mechanical keyboard' }, key: 0, score: 78.26… }

A Matcher prepares a collection once and answers many queries. For a single query, use the standalone bestMatch, search, or searchIter instead.

Metrics and score scales

Metrics are directly callable:

import { ratio } from 'rapidfuzz-js/fuzz'
import {
  distance as levenshteinDistance,
  normalizedSimilarity as levenshteinNormalizedSimilarity,
  similarity as levenshteinSimilarity,
} from 'rapidfuzz-js/levenshtein'

ratio('this is a test', 'this is a test!')
// 96.55172413793103 (0–100)

levenshteinDistance('lewenstein', 'levenshtein')
// 2 (native edit count)

levenshteinSimilarity('abc', 'axc')
// 2 (raw maximum-minus-distance similarity)

levenshteinNormalizedSimilarity('abc', 'axc')
// 0.6666666666666667

Scores are never rescaled between families:

| Operation | Scale | | -------------------------------------- | ---------------------- | | Fuzz similarities | 0–100 | | Raw edit/count distance and similarity | Native algorithm units | | Normalized distance and similarity | 0–1 | | Jaro and Jaro-Winkler measures | 0–1 | | Dice and Cosine measures | 0–1 |

Available subpaths:

rapidfuzz-js/fuzz
rapidfuzz-js/levenshtein
rapidfuzz-js/indel
rapidfuzz-js/lcs
rapidfuzz-js/osa
rapidfuzz-js/cosine
rapidfuzz-js/damerau-levenshtein
rapidfuzz-js/dice
rapidfuzz-js/hamming
rapidfuzz-js/jaro
rapidfuzz-js/jaro-winkler
rapidfuzz-js/prefix
rapidfuzz-js/postfix

Every algorithm subpath exposes distance, similarity, normalizedDistance, and normalizedSimilarity. Levenshtein, Indel, LCS, and Hamming also export editops and opcodes. The Editops and Opcodes they return carry their alignment in operations, a readonly array, and are themselves iterable with a length, so for (const op of editops(a, b)) and [...editops(a, b)] work without reaching through it. Jaro, Jaro-Winkler, Dice, and Cosine are normalized by construction, so their normalized* exports are the same metrics under the names the other algorithms use.

Sørensen-Dice and Cosine

rapidfuzz-js/dice and rapidfuzz-js/cosine compare two sequences as bags of n-grams rather than position by position, which is what you want when word order is unreliable and fuzz's tokenisation is too coarse. Both read the same exact n-gram frequency profile and differ only in how they combine it:

                2 · Σ min(a_g, b_g)                         Σ a_g · b_g
Dice(A, B) = ─────────────────────────      Cosine(A, B) = ───────────────
              gramCount(A) + gramCount(B)                   ‖A‖ · ‖B‖

Cosine here is the dot product of the two frequency vectors — not the intersection-count formula (|A ∩ B| / sqrt(|A| · |B|)) that some libraries ship under the same name. On ab:3, bc:1 against ab:2, bc:2 this one answers 0.894; that one answers 0.75.

Three further choices are worth knowing, because other implementations make them differently:

  • Multiset, not set. A gram occurring three times on one side and twice on the other contributes min(3, 2) = 2 to Dice's overlap sum — four to its numerator, which is twice that — and 3 · 2 = 6 to Cosine's dot product. Dice on ('banana', 'bananas') is 0.909091; a set-based Dice answers 0.857143.
  • No padding. Nothing is added at the ends, so aba and bab have the same bigram multiset and score 1. Implementations that wrap each input in guard characters answer 0.5.
  • Sequences shorter than gramSize have no grams at all, which would make the ratio 0/0. Two such sequences score 1 if they are equal and 0 otherwise; against a sequence that does have grams they score 0. So at the default gramSize of 2, ('a', 'a') is 1, ('a', 'b') is 0, and ('a', 'ab') is 0 — for both metrics.

gramSize is a scorer configuration, not a call argument — the same rule Levenshtein's weights and Hamming's pad follow:

import { createScorer } from 'rapidfuzz-js'
import { similarity as diceSimilarity } from 'rapidfuzz-js/dice'
import { similarity as cosineSimilarity } from 'rapidfuzz-js/cosine'

diceSimilarity('night', 'nacht')
// 0.25 — `ni ig gh ht` against `na ac ch ht` shares only `ht`

cosineSimilarity('night', 'nacht')
// 0.25 — the same, because every gram here occurs once

createScorer(diceSimilarity, { gramSize: 3 }).score('night', 'nacht')
// 0

A scorer left at the default and one written as { gramSize: 2 } prepare interchangeable choices; a scorer at any other depth, or of the other metric, refuses theirs.

Dice also carries an exact upper bound — 2 · min(gA, gB) / (gA + gB), with the 0/0 case above standing in when neither sequence has a gram — that turns down a candidate on gram counts alone, before either profile is built. That makes it markedly cheaper than Cosine under a high threshold when scoring a pair. Cosine has no such bound. Note that a search over raw text profiles each candidate as it reads it, so the bound saves nothing there; prepared choices or a Matcher are what let it apply.

The fuzz subpath is the exception: it exports similarity scorers only. Two of them are easy to mix up:

  • ratio compares the two strings exactly as given, in one pass.
  • weightedRatio also tries substring and word-reordering comparisons and returns the best weighted score.

weightedRatio is the general-purpose choice: it stays high where ratio drops — reordered words, one string contained in the other, large length differences. Reach for the other fuzz scorers (partialRatio, tokenSortRatio, tokenSetRatio, …) when you want exactly one of those strategies.

Scorers

createScorer bundles a metric with its direction, bounds, symmetry, and configuration into a reusable object:

import { createScorer } from 'rapidfuzz-js'
import { distance } from 'rapidfuzz-js/levenshtein'

const scorer = createScorer(distance, {
  weights: { insertion: 1, deletion: 1, substitution: 2 },
})

scorer.direction // 'distance'
scorer.bounds // [0, Infinity]
scorer.symmetric // true
scorer.score('kitten', 'sitting') // 5
scorer.score('kitten', 'sitting', { threshold: 3 }) // undefined

Similarity thresholds are minimums, distance thresholds are maximums. A threshold uses the scorer's own scale and must be finite.

scoreIfMatch returns the thresholded score as a standalone operation; isMatch returns only the boolean.

One query or many

| | | | ------------ | ----------------------------------------------------------- | | bestMatch | the best single match | | search | ranked top matches — five by default, limit: null for all | | searchIter | lazily yields every qualifying match, in source order |

search ranks, so it buffers results; searchIter does not rank and buffers nothing. Each takes only the options it defines — passing limit to bestMatch or searchIter throws rather than being ignored.

One-shot search streams its input and does not retain the collection:

import { bestMatch, createScorer, search, searchIter } from 'rapidfuzz-js'
import { weightedRatio } from 'rapidfuzz-js/fuzz'

const scorer = createScorer(weightedRatio)
const teams = ['Atlanta Falcons', 'New York Jets', 'New York Giants']

bestMatch('new york jet', teams, { scorer })
search('new york', teams, { scorer, threshold: 60, limit: 2 })

for (const match of searchIter('new york', teams, {
  scorer,
  threshold: 60,
})) {
  // qualifying matches arrive lazily in source order
}

A Matcher snapshots the collection and prepares it once:

import { createMatcher } from 'rapidfuzz-js'

const matcher = createMatcher(teams, { scorer })

matcher.size // 3
matcher.best('new york jet')
matcher.search('new york', { limit: null }) // every result, best first
matcher.searchIter('new york', { threshold: 60 }) // lazy, source order

Keys follow the collection: arrays and iterables use source positions, maps keep their keys (anything map-shaped is read as a map, even when typed as an iterable of entries — the runtime cannot tell them apart), and plain objects keep property names. Missing items and missing getText results are skipped by default without renumbering keys; missingItems: 'throw' rejects them instead. A single string is rejected — it is not a collection of characters.

Strings are retained as-is. Other array-like sequences are shallow-copied, so mutating the source later does not change scores. Returned items and nested objects stay live references.

For a Dice or Cosine scorer over a large collection, createIndexedMatcher builds the same Matcher over one inverted n-gram index instead of a prepared handle per choice:

import { createIndexedMatcher, createScorer } from 'rapidfuzz-js'
import { similarity as diceSimilarity } from 'rapidfuzz-js/dice'

const matcher = createIndexedMatcher(files, {
  scorer: createScorer(diceSimilarity, { gramSize: 3 }),
  getText: (file) => file.path,
})
matcher.search('src/algorthms/dice.ts', { limit: 5, threshold: 0.5 })

Every member behaves the same and the scores are exact. On 10,000 file paths a query measured 11-13x faster, construction retaining 256 bytes a choice against 1,282 and costing about 1.2x more. Query scratch is separate from that figure and reused between queries, and an oversized reservation is released once query demand returns to the normal retained range. It is not uniformly faster: a query made of grams nearly every choice shares reaches everything anyway and measured 0.7x. Only dice.similarity and cosine.similarity offer one — anything else throws at construction, a distance scorer is a compile error, and searchIter settles which choices qualify before yielding the first rather than scoring lazily.

Reusable prepared choices

prepareChoice converts one choice into the form the scorer's kernels want and returns it as an opaque handle. Store the handle beside your own data and return it from getPrepared, and searches skip preparation entirely:

import { createScorer, normalizeText, searchIter } from 'rapidfuzz-js'
import { tokenSetRatio } from 'rapidfuzz-js/fuzz'

const scorer = createScorer(tokenSetRatio)
const companies = records.map((record) => ({
  record,
  prepared: scorer.prepareChoice(record.name, { normalize: normalizeText }),
}))

// Cheap guards run first; only survivors are scored.
function* plausible(query: Query) {
  for (const row of companies) {
    if (row.record.country !== query.country) continue
    if (!sharesADigit(row.record.postcode, query.postcode)) continue
    yield row
  }
}

for (const match of searchIter(query.name, plausible(query), {
  scorer,
  getPrepared: (row) => row.prepared,
  // Must be the normalizer the choices were prepared with; a different one,
  // or none, throws.
  normalize: normalizeText,
})) {
  // handles are prepared once, however many queries run
}

This pattern keeps the collection yours: a generator filters candidates before any scoring happens. createMatcher amortizes the same preparation but owns the collection, so there is no place for a guard. It accepts getPrepared too and resolves every handle once at construction.

Normalization is all-or-nothing

Either the library normalizes both sides, or you do. The handle records which:

// Library-managed: the handle records the normalizer, and the search must
// name the same one.
const prepared = scorer.prepareChoice(name, { normalize: normalizeText })
searchIter(query, rows, { scorer, getPrepared, normalize: normalizeText })

// Caller-managed: you normalize both sides yourself and tell the search
// nothing.
const prepared = scorer.prepareChoice(normalizeText(name))
searchIter(normalizeText(query), rows, { scorer, getPrepared })

Mixing the two throws. prepareChoice(normalizeText(name)) produces the same text as the first line but records no normalizer, so a search that normalizes its query cannot verify the choice was normalized the same way, and refuses.

The check compares function identity — two arrow functions with the same body count as different normalizers — so define one function and pass it to both sides. This also means a normalizer must be deterministic: one that reads mutable outside state passes the identity check but can still normalize the two sides differently, and no check can catch that. To reconfigure a normalizer, create a new function instead of mutating captured state.

normalizeText lowercases, replaces every non-alphanumeric character with a space, and trims. Non-string sequences pass through unchanged, so it works as the Normalizer for array-like choices too. Values that are not sequences at all are still refused.

Which scorers accept a handle

Compatibility is decided by identity, not by proving two preparations equivalent:

| Prepared by | Accepted by | | ---------------------------------------------- | -------------------------------------- | | a scorer using a metric's default preparation | any scorer of that metric using it too | | a scorer with configuration the metric records | that scorer alone | | a custom metric's scorer | that scorer alone |

Two separately created createScorer(fuzz.ratio) scorers share handles; a configured or custom scorer owns the handles it made.

Anything else throws: an incompatible handle is refused, and a value that is not a handle at all is refused as invalid. Built-in metrics carry their identity in the type, so most of these mistakes are compile errors first. Spell a stored handle's type as PreparedChoiceOf<typeof scorer> and a stored scorer's as ScorerOf<typeof tokenSetRatio>. The identity is the metric's own id literal — declaration emit spells it Scorer<'similarity', 'fuzz.tokenSetRatio'> without importing anything from this package. Widening to Scorer<'similarity'> drops the metric from the type, leaving only the runtime check.

Prepared mode is strict: missingItems and getText are not accepted — there is nothing to skip or extract. normalize applies to the query only, never to a choice; a choice is normalized when it is prepared.

Matrices and paired scoring

import { scoreMatrix, scorePairs } from 'rapidfuzz-js'

const matrix = scoreMatrix(['cat', 'dog'], ['cats', 'dogs'], { scorer })
matrix.rows
matrix.cols
matrix.at(0, 0)
matrix.data // row-major Float64Array

scorePairs(['cat', 'dog'], ['cats', 'dogs'], { scorer })

Set into to f64, f32, i32, i16, i8, u32, u16, u8, or u8c to choose the typed-array storage. Batch threshold uses the scorer's unscaled domain; scoreMultiplier is applied afterward:

import { normalizedSimilarity } from 'rapidfuzz-js/levenshtein'

const normalized = createScorer(normalizedSimilarity)
scoreMatrix(['cat'], ['cats'], {
  scorer: normalized,
  threshold: 0.5,
  scoreMultiplier: 100,
  into: 'u8',
})

A score the chosen element type cannot hold is a RangeError, not a wrapped number: scoreMultiplier: 3 on a 0..100 scorer reaches 300, which a u8 would otherwise store as 44. The check costs nothing where the scorer's bounds and multiplier prove every score fits, so a 0..100 scorer into u8 stays as it was, and a Infinity-bounded distance into u8 is still allowed — it is refused when a score actually arrives that does not fit, not up front. u8c is the exception, and the way to ask for the lossy behaviour on purpose: Uint8ClampedArray saturates to 0..255 by definition.

RapidFuzz capability mapping

| RapidFuzz | rapidfuzz-js | | ------------------------------ | ------------------------------------------------------------- | | fuzz.ratio | ratio | | fuzz.WRatio | weightedRatio | | fuzz.QRatio | none — see below | | process.extractOne | bestMatch | | process.extract | search | | process.extract_iter | searchIter | | process.cdist / cpdist | scoreMatrix / scorePairs | | score_cutoff | threshold | | score_multiplier | scoreMultiplier | | scorer_kwargs | createScorer(metric, configuration) | | repeated prepared extraction | createMatcher, or scorer.prepareChoice with getPrepared | | large-collection n-gram search | createIndexedMatcher (no RapidFuzz counterpart) |

For everything RapidFuzz spells differently — extraction, cutoffs, scorer configuration, prepared reuse — the table above is the whole translation.

fuzz.QRatio is deliberately absent. It is upstream's own fuzz.ratio with one difference: two empty strings score 0 rather than 100. Its processor is opt-in and defaults to none, so it does no normalization — the name is a fuzzywuzzy inheritance rather than a separate algorithm, and fuzzball, the JavaScript port of fuzzywuzzy, does not ship it either. Callers who want that empty-string rule can write it in a line.

Upgrading from 0.11

Every rapidfuzz-js/fuzz export was renamed in 0.12.0, so that the scorers carry RapidFuzz's own vocabulary. Nothing else moved: the package root and the distance subpaths are unchanged, and no score changed with the names.

| 0.11 | 0.12 | | ---------------------------- | ----------------------- | | similarity | ratio | | partialSimilarity | partialRatio | | partialSimilarityAlignment | partialRatioAlignment | | tokenSimilarity | tokenRatio | | tokenSortSimilarity | tokenSortRatio | | tokenSetSimilarity | tokenSetRatio | | partialTokenSimilarity | partialTokenRatio | | partialTokenSortSimilarity | partialTokenSortRatio | | partialTokenSetSimilarity | partialTokenSetRatio | | weightedSimilarity | weightedRatio |

The metric brands moved with them, so a stored type such as Scorer<'similarity', 'fuzz.tokenSetSimilarity'> becomes Scorer<'similarity', 'fuzz.tokenSetRatio'>. The first argument there is the score direction and is unrelated to the rename.

Missing and invalid values

Only null and undefined count as missing. Similarity scorers return 0 for a missing operand by default:

const strict = createScorer(weightedRatio, { missing: 'throw' })
strict.score(null, 'text') // throws TypeError

Two missing operands are also 0, not 100: unknown compared with unknown is not a match, and scoring it perfect would sort every missing record to the top of a search.

Distance scorers always throw on missing operands. Empty sequences are valid. Numbers (including NaN), booleans, and objects without a valid array-like length are invalid.

Empty inputs are where the fuzz scorers disagree with each other, and deliberately so — tokenSetRatio, partialTokenSetRatio and weightedRatio answer 0 for two empty inputs where ratio and the sort-based scorers answer 100. FuzzyWuzzy returns 0 there and RapidFuzz keeps it (issue 110), so this port does too.

Whitespace-only inputs split the three: the two token-set scorers still answer 0, because a side that tokenizes to nothing has no set to intersect, while weightedRatio sees two non-empty strings and scores identical whitespace 100.

Options objects — for searches, Matcher methods, scoreMatrix, scorePairs, and prepareChoice — reject unknown keys:

search(query, choices, { scorer, thresold: 90 })
// TypeError: unknown search option 'thresold'

Without this, the misspelling would typecheck (TypeScript's excess-property check only covers fresh object literals) and silently return unthresholded results. The threshold argument to score, isMatch, and scoreIfMatch is not enumerated: its one key is required, so a misspelling already fails with threshold must be finite.

Custom metrics

Custom metrics declare the metadata needed for ordering and validation:

const custom = createScorer((a, b) => (a === b ? 1 : 0), {
  direction: 'similarity',
  bounds: [0, 1],
  symmetric: true,
})

Every custom result must be finite and inside its declared bounds; results are validated before thresholding, ordering, or pruning.

A custom scorer prepares choices like any other, and its handles belong to it alone — two scorers built from the same function do not share handles, because nothing about a plain function proves they are interchangeable. A built-in scorer's handle holds a precomputed kernel representation; a custom scorer's holds an owned snapshot of the sequence, since there is nothing else to precompute for a plain function. Ownership, the compatibility check, and the opaque shape are the same for both.

Small by construction

Bundle size is a structural property here, not something measured after the fact:

  • "sideEffects": false, and nothing runs at import time, so bundlers can drop whatever a build does not reach.
  • Standalone named functions, never namespace objects — touching one member of a namespace object keeps every member alive.
  • Algorithms live on their own subpaths and are never re-exported from the root: importing rapidfuzz-js/jaro pays for Jaro and nothing else.
  • No runtime dependencies and no Node built-ins, so the same build runs unchanged in browsers and edge runtimes.

Performance and package validation

The benchmark vocabulary maps directly to the public API:

| Workload | API | | ----------------------------------- | ---------------------------------- | | One best result | bestMatch | | Top N results | search with limit: N | | Lazy qualifying results | searchIter | | Prepare a reusable collection | createMatcher construction | | Repeated prepared query | matcher.best/search/searchIter | | Repeated query, caller's collection | one-shot search with getPrepared |

The release check runs type checking, linting, formatting, all functional tests, the build, export-map validation, package validation, and tarball inspection. Source maps ship with embedded source content; TypeScript source files are not included in the package.

License

MIT