TypeScript library of ranked voting methods, published to NPM.
yarn add votes
# or: npm install votesimport { Borda } from 'votes'
const borda = new Borda({
candidates: ['Lion', 'Bear', 'Sheep'],
ballots: [
{ ranking: [['Lion'], ['Bear'], ['Sheep']], weight: 4 },
{ ranking: [['Sheep'], ['Bear'], ['Lion']], weight: 3 },
{ ranking: [['Bear', 'Sheep'], ['Lion']], weight: 2 },
],
})
borda.scores()
// { Lion: 17, Bear: 19, Sheep: 18 }
borda.ranking()
// [ ['Bear'], ['Sheep'], ['Lion'] ]Round-based methods (InstantRunoff, Baldwin, Coombs, Nanson,
TwoRoundRunoff, BottomTwoRunoff) accept a tieBreakers array. When multiple
candidates tie for last place in a round, tiebreakers are applied in order until
one candidate can be singled out.
import {
InstantRunoff,
RandomCandidates,
Borda,
Copeland,
tb,
rngGenerator,
} from 'votes'
// Simple: pass constructors directly
new InstantRunoff({ candidates, ballots, tieBreakers: [Borda, Copeland] })
// tb() lets you pass extra constructor options
const rng = rngGenerator('seed')
new InstantRunoff({
candidates,
ballots,
tieBreakers: [
Copeland, // first try Copeland
tb(Borda, { full: true }), // run Borda on ALL candidates, not just the tied subset
tb(Borda, { stable: true }), // recurse into sub-ties until stable
tb(RandomCandidates, { rng }), // last resort: random with seeded RNG
],
})| Option | Default | Description |
|---|---|---|
full |
false |
Run the tiebreaker on all candidates instead of the tied subset. Useful when a method's relative ranking is more meaningful globally. |
stable |
false |
After one pass, recurse into any remaining sub-ties until no further progress can be made. |
| Any other prop | — | Passed through to the method constructor (e.g. rng for random methods). |
Method type is auto-detected: matrix methods (Copeland, Schulze, …) receive
a pairwise matrix; ballot methods receive the filtered ballots; candidates-only
methods (RandomCandidates) receive just the candidate list.
computeRounds() returns detailed per-round results:
const rounds = new InstantRunoff({
candidates,
ballots,
tieBreakers: [Borda],
}).computeRounds()
rounds[0].roundResult
// {
// scores: { a: 5, b: 3, c: 3 },
// qualified: ['a'],
// eliminated: ['c'], // resolved by tiebreaker
// tieBreakSteps: [{
// tbIndex: 0, // first tiebreaker
// tbName: 'Borda',
// input: ['b', 'c'], // tied candidates
// ranking: [['b'], ['c']], // tiebreaker's verdict
// resolved: ['b'], // promoted out of tie
// remaining: ['c'], // eliminated
// }]
// }Election chains pre-built ranker instances. The first provides the primary
ranking; subsequent rankers refine any remaining ties.
import {
Election,
InstantRunoff,
Schulze,
matrixFromBallots,
tb,
Copeland,
} from 'votes'
const election = new Election({
rankers: [
new InstantRunoff({ candidates, ballots, tieBreakers: [tb(Copeland)] }),
new Schulze(matrixFromBallots(ballots, candidates)),
],
})
election.ranking() // final ranking after all tie-breaking
election.result() // { ranking, steps: StepResult[] }Each StepResult records rankerName, before, after, and optionally
rounds / scores from that step.
| Method | Class | Input |
|---|---|---|
| Absolute majority | AbsoluteMajority |
ballots |
| Approval voting | Approbation |
ballots |
| Baldwin method | Baldwin |
ballots |
| Borda count | Borda |
ballots |
| Bottom-two-runoff | BottomTwoRunoff |
ballots |
| Coombs' method | Coombs |
ballots |
| Copeland's method | Copeland |
matrix |
| First-past-the-post | FirstPastThePost |
ballots |
| Instant-runoff (IRV) | InstantRunoff |
ballots |
| Kemeny–Young |
Kemeny |
matrix |
| Majority judgment | MajorityJudgment |
ballots (6 grades) |
| Maximal lotteries | MaximalLotteries |
matrix |
| Minimax Condorcet | Minimax |
matrix |
| Minimax-TD | MinimaxTD |
matrix |
| Nanson method | Nanson |
ballots |
| Random candidate | RandomCandidates |
— |
| Random dictator | RandomDictator |
ballots |
| Randomized Condorcet | RandomizedCondorcet |
matrix |
| Ranked pairs | RankedPairs |
matrix |
| Schulze method | Schulze |
matrix |
| Smith's method | Smith |
matrix |
| Two-round runoff | TwoRoundRunoff |
ballots |
Kemeny runs in O(n!) time — impractical beyond ~8 candidates.
Matrix-input methods take the output of
matrixFromBallots(ballots, candidates).
BottomTwoRunoff always prepends tb(FirstPastThePost) to tieBreakers — that
FPTP step is the head-to-head runoff mechanism, not a fallback. It will appear
as the first entry in tieBreakSteps. User-supplied tieBreakers fire after it
only if the head-to-head itself ties.
Every method also exposes deTie(), which recursively resolves ties by
re-running the same method on each tied subset:
new Borda({ candidates, ballots }).deTie()
// same as ranking() but each tied tier is re-ranked with Borda on that subsetimport { matrixFromBallots, rngGenerator } from 'votes'
// Build a pairwise matrix from ballots
const matrix = matrixFromBallots(ballots, candidates)
// Seeded RNG for reproducible random methods
const rng = rngGenerator('my-seed')
new RandomCandidates({ candidates, rng })Parse and serialize the
Condorcet Election Format (.blt-style text
files):
import {
parseCondorcetElectionFormat,
stringifyCondorcetElectionFormat,
} from 'votes'
const { candidates, ballots } = parseCondorcetElectionFormat(`
#/Candidates: A; B; C
A > B > C * 3
B > C > A * 2
`)
const text = stringifyCondorcetElectionFormat({ candidates, ballots })- Demo/Playground: rank-votes.vercel.app
- Blog post: Ranked voting systems
- API docs: lzear.github.io/votes
- Reference: Comparison of electoral systems (Wikipedia)
Contributions, issues and feature requests are welcome. Feel free to check the issues page.