voting-engine
v0.1.0
Published
A TypeScript library for implementing and running voting systems: plurality, approval, ranked-choice (IRV), Borda count, Condorcet (Copeland), and single transferable vote (STV).
Maintainers
Readme
voting-engine
A TypeScript library for implementing and running voting systems. Ships six well-known methods and a shared type system, so you can swap methods without rewriting your ballot-handling code.
- Plurality (first-past-the-post) — single choice, most votes wins
- Approval — approve any number of candidates, most approvals wins
- Borda Count — points by rank position, most points wins
- Instant-Runoff Voting (ranked-choice / RCV) — round-by-round elimination until a majority forms
- Condorcet (Copeland) — pairwise head-to-head comparisons, with cycle fallback
- Single Transferable Vote (STV) — multi-winner, proportional, with fractional surplus transfer
Install
npm install voting-engineQuick start
import { PluralityVoting, type Candidate, type SingleChoiceBallot } from "voting-engine";
const candidates: Candidate[] = [
{ id: "alice", name: "Alice" },
{ id: "bob", name: "Bob" },
];
const ballots: SingleChoiceBallot[] = [
{ choice: "alice" },
{ choice: "alice" },
{ choice: "bob" },
];
const result = new PluralityVoting().tally(candidates, ballots);
console.log(result.winner); // "alice"
console.log(result.tally); // { alice: 2, bob: 1 }Every method implements the same interface:
interface VotingSystem<TBallot extends Ballot> {
readonly name: string;
tally(candidates: Candidate[], ballots: TBallot[]): VotingResult;
}So you can pick a method at runtime and reuse the same calling code, as long as you feed it the matching ballot shape.
Ranked-choice (instant-runoff) example
import { RankedChoiceVoting, type RankedBallot } from "voting-engine";
const candidates = [{ id: "A" }, { id: "B" }, { id: "C" }];
const ballots: RankedBallot[] = [
...Array(45).fill({ ranking: ["A", "B", "C"] }),
...Array(30).fill({ ranking: ["B", "C", "A"] }),
...Array(25).fill({ ranking: ["C", "B", "A"] }),
];
const result = new RankedChoiceVoting().tally(candidates, ballots);
console.log(result.winner); // "B" — C is eliminated first, its votes flow to B
console.log(result.rounds); // full round-by-round breakdownMulti-winner example (STV)
import { SingleTransferableVote, type RankedBallot } from "voting-engine";
const candidates = [{ id: "A" }, { id: "B" }, { id: "C" }, { id: "D" }];
const ballots: RankedBallot[] = [
/* ... ranked ballots ... */
];
const result = new SingleTransferableVote({ seats: 2 }).tally(candidates, ballots);
console.log(result.winners); // e.g. ["A", "B"]Handling ties
Every method accepts an optional tieBreaker in its constructor. It receives
the tied candidate ids and should return the subset to keep as winners (a
single id to fully resolve the tie, or the same list back to leave it
unresolved):
new PluralityVoting({
tieBreaker: (tied) => [tied.sort()[0]], // alphabetical tiebreak
});If a tie is never resolved, result.winner is null and the tied ids are
listed in result.tiedCandidates.
Ballot types
Each method expects a specific ballot shape:
| Method | Ballot type | Shape |
|---|---|---|
| PluralityVoting | SingleChoiceBallot | { choice: CandidateId } |
| ApprovalVoting | ApprovalBallot | { approved: CandidateId[] } |
| BordaCount | RankedBallot | { ranking: CandidateId[] } |
| RankedChoiceVoting | RankedBallot | { ranking: CandidateId[] } |
| CondorcetVoting | RankedBallot | { ranking: CandidateId[] } |
| SingleTransferableVote | RankedBallot | { ranking: CandidateId[] } |
ScoredBallot ({ scores: Record<CandidateId, number> }) is also exported
for building your own score-based method (e.g. STAR voting or range voting)
on top of the shared types and validation helpers.
Errors
UnknownCandidateError— a ballot references a candidate id not present in your candidate listInvalidElectionError— structural problems (no candidates, duplicate ids, more STV seats than candidates)
Both extend VotingSystemError.
Building your own method
The shared helpers in the package (validateCandidates, zeroTally,
findLeaders, findTrailers) are exported so you can implement additional
methods (e.g. STAR voting, cumulative voting) consistent with the rest of
the library — just implement the VotingSystem<TBallot> interface.
Scripts
npm run build # bundle to dist/ (ESM + CJS + .d.ts)
npm test # run the test suite (vitest)
npm run typecheck # type-check without emittingLicense
MIT
