match-sorter
Simple, expected, and deterministic best-match sorting of an array in JavaScript
Demo
The problem
- You have a list of dozens, hundreds, or thousands of items
- You want to filter and sort those items intelligently (maybe you have a
filter input for the user)
- You want simple, expected, and deterministic sorting of the items (no fancy
math algorithm that fancily changes the sorting as they type)
This solution
This follows a simple and sensible (user friendly) algorithm that makes it easy
for you to filter and sort a list of items based on given input. Items are
ranked based on sensible criteria that result in a better user experience.
To explain the ranking system, I'll use countries as an example:
- CASE SENSITIVE EQUALS: Case-sensitive equality trumps all. These will be
first. (ex.
France
would match France
, but not france
) - EQUALS: Case-insensitive equality (ex.
France
would match france
) - STARTS WITH: If the item starts with the given value (ex.
Sou
would
match South Korea
or South Africa
) - WORD STARTS WITH: If the item has multiple words, then if one of those
words starts with the given value (ex.
Repub
would match
Dominican Republic
) - CONTAINS: If the item contains the given value (ex.
ham
would match
Bahamas
) - ACRONYM: If the item's acronym is the given value (ex.
us
would match
United States
) - SIMPLE MATCH: If the item has letters in the same order as the letters
of the given value (ex.
iw
would match Zimbabwe
, but not Kuwait
because it must be in the same order). Furthermore, if the item is a closer
match, it will rank higher (ex. ua
matches Uruguay
more closely than
United States of America
, therefore Uruguay
will be ordered before
United States of America
)
This ranking seems to make sense in people's minds. At least it does in mine.
Feedback welcome!
Installation
This module is distributed via npm which is bundled with node and
should be installed as one of your project's dependencies
:
npm install match-sorter
Usage
import {matchSorter} from 'match-sorter'
const list = ['hi', 'hey', 'hello', 'sup', 'yo']
matchSorter(list, 'h')
matchSorter(list, 'y')
matchSorter(list, 'z')
Advanced options
keys: [string]
Default: undefined
By default it just uses the value itself as above. Passing an array tells
match-sorter which keys to use for the ranking.
const objList = [
{name: 'Janice', color: 'Green'},
{name: 'Fred', color: 'Orange'},
{name: 'George', color: 'Blue'},
{name: 'Jen', color: 'Red'},
]
matchSorter(objList, 'g', {keys: ['name', 'color']})
matchSorter(objList, 're', {keys: ['color', 'name']})
Array of values: When the specified key matches an array of values, the best
match from the values of in the array is going to be used for the ranking.
const iceCreamYum = [
{favoriteIceCream: ['mint', 'chocolate']},
{favoriteIceCream: ['candy cane', 'brownie']},
{favoriteIceCream: ['birthday cake', 'rocky road', 'strawberry']},
]
matchSorter(iceCreamYum, 'cc', {keys: ['favoriteIceCream']})
Nested Keys: You can specify nested keys using dot-notation.
const nestedObjList = [
{name: {first: 'Janice'}},
{name: {first: 'Fred'}},
{name: {first: 'George'}},
{name: {first: 'Jen'}},
]
matchSorter(nestedObjList, 'j', {keys: ['name.first']})
const nestedObjList = [
{name: [{first: 'Janice'}]},
{name: [{first: 'Fred'}]},
{name: [{first: 'George'}]},
{name: [{first: 'Jen'}]},
]
matchSorter(nestedObjList, 'j', {keys: ['name.0.first']})
This even works with arrays of multiple nested objects: just specify the key
using dot-notation with the *
wildcard instead of a numeric index.
const nestedObjList = [
{aliases: [{name: {first: 'Janice'}},{name: {first: 'Jen'}}]},
{aliases: [{name: {first: 'Fred'}},{name: {first: 'Frederic'}}]},
{aliases: [{name: {first: 'George'}},{name: {first: 'Georgie'}}]},
]
matchSorter(nestedObjList, 'jen', {keys: ['aliases.*.name.first']})
matchSorter(nestedObjList, 'jen', {keys: ['aliases.0.name.first']})
Property Callbacks: Alternatively, you may also pass in a callback function
that resolves the value of the key(s) you wish to match on. This is especially
useful when interfacing with libraries such as Immutable.js
const list = [{name: 'Janice'}, {name: 'Fred'}, {name: 'George'}, {name: 'Jen'}]
matchSorter(list, 'j', {keys: [item => item.name]})
For more complex structures, expanding on the nestedObjList
example above, you
can use map
:
const nestedObjList = [
{
name: [
{first: 'Janice', last: 'Smith'},
{first: 'Jon', last: 'Doe'},
],
},
{
name: [
{first: 'Fred', last: 'Astaire'},
{first: 'Jenny', last: 'Doe'},
{first: 'Wilma', last: 'Flintstone'},
],
},
]
matchSorter(nestedObjList, 'doe', {
keys: [
item => item.name.map(i => i.first),
item => item.name.map(i => i.last),
],
})
Threshold: You may specify an individual threshold for specific keys. A key
will only match if it meets the specified threshold. For more information
regarding thresholds see below
const list = [
{name: 'Fred', color: 'Orange'},
{name: 'Jen', color: 'Red'},
]
matchSorter(list, 'ed', {
keys: [{threshold: matchSorter.rankings.STARTS_WITH, key: 'name'}, 'color'],
})
Min and Max Ranking: You may restrict specific keys to a minimum or maximum
ranking by passing in an object. A key with a minimum rank will only get
promoted if there is at least a simple match.
const tea = [
{tea: 'Earl Grey', alias: 'A'},
{tea: 'Assam', alias: 'B'},
{tea: 'Black', alias: 'C'},
]
matchSorter(tea, 'A', {
keys: ['tea', {maxRanking: matchSorter.rankings.STARTS_WITH, key: 'alias'}],
})
const tea = [
{tea: 'Milk', alias: 'moo'},
{tea: 'Oolong', alias: 'B'},
{tea: 'Green', alias: 'C'},
]
matchSorter(tea, 'oo', {
keys: ['tea', {minRanking: matchSorter.rankings.EQUAL, key: 'alias'}],
})
threshold: number
Default: MATCHES
Thresholds can be used to specify the criteria used to rank the results.
Available thresholds (from top to bottom) are:
- CASE_SENSITIVE_EQUAL
- EQUAL
- STARTS_WITH
- WORD_STARTS_WITH
- CONTAINS
- ACRONYM
- MATCHES (default value)
- NO_MATCH
const fruit = ['orange', 'apple', 'grape', 'banana']
matchSorter(fruit, 'ap', {threshold: matchSorter.rankings.NO_MATCH})
const things = ['google', 'airbnb', 'apple', 'apply', 'app'],
matchSorter(things, 'app', {threshold: matchSorter.rankings.EQUAL})
const otherThings = ['fiji apple', 'google', 'app', 'crabapple', 'apple', 'apply']
matchSorter(otherThings, 'app', {threshold: matchSorter.rankings.WORD_STARTS_WITH})
keepDiacritics: boolean
Default: false
By default, match-sorter will strip diacritics before doing any comparisons.
This is the default because it makes the most sense from a UX perspective.
You can disable this behavior by specifying keepDiacritics: true
const thingsWithDiacritics = [
'jalapeño',
'à la carte',
'café',
'papier-mâché',
'à la mode',
]
matchSorter(thingsWithDiacritics, 'aa')
matchSorter(thingsWithDiacritics, 'aa', {keepDiacritics: true})
matchSorter(thingsWithDiacritics, 'à', {keepDiacritics: true})
baseSort: function(itemA, itemB): -1 | 0 | 1
Default: (a, b) => String(a.rankedValue).localeCompare(b.rankedValue)
By default, match-sorter uses the String.localeCompare
function to tie-break
items that have the same ranking. This results in a stable, alphabetic sort.
const list = ['C apple', 'B apple', 'A apple']
matchSorter(list, 'apple')
You can customize this behavior by specifying a custom baseSort
function:
const list = ['C apple', 'B apple', 'A apple']
matchSorter(list, 'apple', {baseSort: (a, b) => (a.index < b.index ? -1 : 1)})
sorter: function(rankedItems): rankedItems
Default:
matchedItems => matchedItems.sort((a, b) => sortRankedValues(a, b, baseSort))
By default, match-sorter uses an internal sortRankedValues
function to sort
items after matching them.
You can customize the core sorting behavior by specifying a custom sorter
function:
Disable sorting entirely:
const list = ['appl', 'C apple', 'B apple', 'A apple', 'app', 'applebutter']
matchSorter(list, 'apple', {sorter: rankedItems => rankedItems})
Return the unsorted rankedItems, but in reverse order:
const list = ['appl', 'C apple', 'B apple', 'A apple', 'app', 'applebutter']
matchSorter(list, 'apple', {sorter: rankedItems => [...rankedItems].reverse()})
Recipes
Match PascalCase, camelCase, snake_case, or kebab-case as words
By default, match-sorter
assumes spaces to be the word separator. However, if
your data has a different word separator, you can use a property callback to
replace your separator with spaces. For example, for snake_case
:
const list = [
{name: 'Janice_Kurtis'},
{name: 'Fred_Mertz'},
{name: 'George_Foreman'},
{name: 'Jen_Smith'},
]
matchSorter(list, 'js', {keys: [item => item.name.replace(/_/g, ' ')]})
Match many words across multiple fields (table filtering)
By default, match-sorter
will return matches from objects where one of the
properties matches the entire search term. For multi-column data sets it can
be beneficial to split words in search string and match each word separately.
This can be done by chaining match-sorter
calls.
The benefit of this is that a filter string of "two words" will match both "two"
and "words", but will return rows where the two words are found in different
columns as well as when both words match in the same column. For single-column
matches it will also return matches out of order (column = "wordstwo" will match
just as well as column="twowords", the latter getting a higher score).
function fuzzySearchMultipleWords(
rows,
keys,
filterValue: string,
) {
if (!filterValue || !filterValue.length) {
return rows
}
const terms = filterValue.split(' ')
if (!terms) {
return rows
}
return terms.reduceRight(
(results, term) => matchSorter(results, term, {keys}),
rows,
)
}
Multi-column code sandbox
Inspiration
Actually, most of this code was extracted from the very first library I ever
wrote: genie!
Other Solutions
You might try Fuse.js. It uses advanced math
fanciness to get the closest match. Unfortunately what's "closest" doesn't
always really make sense. So I extracted this from genie.
Issues
Looking to contribute? Look for the Good First Issue
label.
🐛 Bugs
Please file an issue for bugs, missing documentation, or unexpected behavior.
See Bugs
💡 Feature Requests
Please file an issue to suggest new features. Vote on feature requests by adding
a 👍. This helps maintainers prioritize what to work on.
See Feature Requests
Contributors ✨
Thanks goes to these people (emoji key):
This project follows the all-contributors specification.
Contributions of any kind welcome!
LICENSE
MIT