Poker and TypeScript
Poker is probably the best-known and most-played card game today, from family-friendly games to professional multimillion-dollar tournaments. In this article, we’ll mix the game with some interesting pattern-detection algorithms: given the cards you, a player, were dealt, what hand do you have? We’ll be using a functional approach, of course, and we’ll see testing and coding techniques you may use for your own front- and back-end work.
We’ll produce a full TypeScript solution for our hand-ranking problem, with all the needed typing. We’ll first define the needed data types, and then focus on the pattern-finding algorithm, which has several interesting points.
Types and conversions
Let’s start by defining some types we’ll need. Rank
and Suit
are straightforward union types.
type Rank =
| 'A' | '2' | '3' | '4' | '5' | '6' | '7'
| '8' | '9' | '10' | 'J' | 'Q' | 'K'
type Suit = '♥' | '♦' | '♠' | '♣';
Internally, we’ll work with Card
objects, transforming ranks and suits into numbers. Cards will be represented with values from 1 (Ace) to 13 (King) and suits from 1 (hearts) to 4 (clubs). The rankToNumber()
and suitToNumber()
functions handle the conversions from Rank
and Suit
values to numbers.
type Card = { rank: number; suit: number };
const rankToNumber = (rank: Rank): number =>
rank === 'A' ? 1
: rank === 'J' ? 11
: rank === 'Q' ? 12
: rank === 'K' ? 13
: Number(rank);
const suitToNumber = (suit: Suit): number =>
suit === '♥' ? 1
: suit === '♦' ? 2
: suit === '♠' ? 3
: /* suit === "♣" */ 4;
These types are for internal work; we must also define the type of the result of our rank-detection algorithm. We need an enum type for the possible values of a hand. The values are ordered from lowest (“high card”) to highest (“royal flush”).
enum Hand {
HighCard,
OnePair,
TwoPairs,
ThreeOfAKind,
Straight,
Flush,
FullHouse,
FourOfAKind,
StraightFlush,
RoyalFlush
}
Now we got all the types we need; let’s move on to the algorithms!
What hand do we have?
Let’s start by defining the handRank()
function we’ll build. Our function will receive a tuple of five cards and return a Hand
result.
export function handRank(
cardStrings: [string, string, string, string, string]
): Hand {
.
.
.
}
Since dealing with strings is harder than needed, we’ll transform the card strings into Card
objects with numerical rank
and suit
values, making our algorithms easier to write.
const cards: Card[] = cardStrings.map((str: string) => ({
rank: rankToNumber(
str.substring(0, str.length - 1) as Rank
),
suit: suitToNumber(str.at(-1) as Suit)
}));
.
.
.
// continues...
The key to determining the value of a player’s hand depends on knowing how many cards we have of each rank and how many counts we got. For instance, if we have three Jacks and two Kings, the count for Jacks is 3, and the count for Kings is 2. Then, knowing we got one count of three and one count of two, we can tell we have a full house. Another example: if we have two Queens, two Aces, and one Five, we get two counts of two and one count of one; we have two pairs.
Producing the counts is straightforward. We want the Aces’ count to be at countByRank[1]
so we won’t use the initial place in the countByRank
array. Similarly, the counts for suits will be in countBySuit[1]
through countBySuit[4]
, so we won’t use the initial place in that array either.
// ...continued
.
.
.
const countBySuit = new Array(5).fill(0);
const countByRank = new Array(15).fill(0);
const countBySet = new Array(5).fill(0);
cards.forEach((card: Card) => {
countByRank[card.rank]++;
countBySuit[card.suit]++;
});
countByRank.forEach(
(count: number) => count && countBySet[count]++
);
.
.
.
// continues...
We must not forget that Aces may be at the beginning of straights (A-2-3-4-5) or at the end (10-J-Q-K-A). We can deal with that by replicating the Aces count after the Kings.
// ...continued
.
.
.
countByRank[14] = countByRank[1];
.
.
.
// continues...
Now we can start recognizing hands. We need only look at counts by rank to recognize several hands:
// ...continued
.
.
.
if (countBySet[4] === 1 && countBySet[1] === 1)
return Hand.FourOfAKind;
else if (countBySet[3] && countBySet[2] === 1)
return Hand.FullHouse;
else if (countBySet[3] && countBySet[1] === 2)
return Hand.ThreeOfAKind;
else if (countBySet[2] === 2 && countBySet[1] === 1)
return Hand.TwoPairs;
else if (countBySet[2] === 1 && countBySet[1] === 3)
return Hand.OnePair;
.
.
.
// continues...
For instance, with four cards of the same rank, we know the player would have a “four of a kind” result. You might ask: if countBySet[4] === 1
, why are you also testing that countBySet[1] === 1
? If four cards are equal in rank, there must be a single other card, right? The answer is “defensive programming” — while I was developing the code, sometimes bugs crept in, and being extra specific in tests helped smoke the bugs out.
The cases above include all possibilities with some rank appearing more than once. We must deal with other cases, including straights, flushes, and “high card” results.
// ...continued
.
.
.
else if (countBySet[1] === 5) {
if (countByRank.join('').includes('11111'))
return !countBySuit.includes(5)
? Hand.Straight
: countByRank.slice(10).join('') === '11111'
? Hand.RoyalFlush
: Hand.StraightFlush;
else {
return countBySuit.includes(5)
? Hand.Flush
: Hand.HighCard;
}
} else {
throw new Error(
'Unknown hand! This cannot happen! Bad logic!'
);
}
Here we have defensive code again; even when we know that we had five different ranks, we make sure that the logic worked well and even have a throw
in case something goes wrong. During development, I got that error more times than I wish to admit!
How can we test for a straight? We should have five consecutive ranks. If we look at the countByRank
array, it should have five consecutive ones, so by doing countByRank.join()
and checking if the produced string includes a 11111
, we are sure of the straight.
We must distinguish several cases:
- if we don’t have five cards of the same suit, it’s a common straight
- if all cards are the same suit, if the straight ends with an Ace, it’s a Royal Flush
- if all cards are the same suit, but we don’t end with an Ace, we have a Straight Flush
If we don’t have a straight, there are just two possibilities:
- if all cards are the same suit, we have a Flush
- if not all cards are the same suit, we have a “High Card”
We’re done! The complete function is as follows:
export function handRank(
cardStrings: [string, string, string, string, string]
): Hand {
const cards: Card[] = cardStrings.map((str: string) => ({
rank: rankToNumber(
str.substring(0, str.length - 1) as Rank
),
suit: suitToNumber(str.at(-1) as Suit)
}));
// We won't use the [0] place in the following arrays
const countBySuit = new Array(5).fill(0);
const countByRank = new Array(15).fill(0);
const countBySet = new Array(5).fill(0);
cards.forEach((card: Card) => {
countByRank[card.rank]++;
countBySuit[card.suit]++;
});
countByRank.forEach(
(count: number) => count && countBySet[count]++
);
// count the A also as a 14, for straights
countByRank[14] = countByRank[1];
if (countBySet[4] === 1 && countBySet[1] === 1)
return Hand.FourOfAKind;
else if (countBySet[3] && countBySet[2] === 1)
return Hand.FullHouse;
else if (countBySet[3] && countBySet[1] === 2)
return Hand.ThreeOfAKind;
else if (countBySet[2] === 2 && countBySet[1] === 1)
return Hand.TwoPairs;
else if (countBySet[2] === 1 && countBySet[1] === 3)
return Hand.OnePair;
else if (countBySet[1] === 5) {
if (countByRank.join('').includes('11111'))
return !countBySuit.includes(5)
? Hand.Straight
: countByRank.slice(10).join('') === '11111'
? Hand.RoyalFlush
: Hand.StraightFlush;
else {
/* !countByRank.join("").includes("11111") */
return countBySuit.includes(5)
? Hand.Flush
: Hand.HighCard;
}
} else {
throw new Error(
'Unknown hand! This cannot happen! Bad logic!'
);
}
}
Session Replay for Developers
Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — an open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data. Check our GitHub repo and join the thousands of developers in our community.
Testing the code
The basic kind of test I wrote was a lot of lines like the following. I ran the code and verified that the logged numbers were right.
console.log(handRank(['3♥', '5♦', '8♣', 'A♥', '6♠'])); // 0
console.log(handRank(['3♥', '5♦', '8♣', 'A♥', '5♠'])); // 1
console.log(handRank(['3♥', '5♦', '3♣', 'A♥', '5♠'])); // 2
console.log(handRank(['3♥', '5♦', '8♣', '5♥', '5♠'])); // 3
console.log(handRank(['3♥', '2♦', 'A♣', '5♥', '4♠'])); // 4
console.log(handRank(['J♥', '10♦', 'A♣', 'Q♥', 'K♠'])); // 4
console.log(handRank(['3♥', '4♦', '7♣', '5♥', '6♠'])); // 4
console.log(handRank(['3♥', '4♥', '9♥', '5♥', '6♥'])); // 5
console.log(handRank(['3♥', '5♦', '3♣', '5♥', '3♠'])); // 6
console.log(handRank(['3♥', '3♦', '3♣', '5♥', '3♠'])); // 7
console.log(handRank(['3♥', '4♥', '7♥', '5♥', '6♥'])); // 8
console.log(handRank(['K♥', 'Q♥', 'A♥', '10♥', 'J♥'])); // 9
After visually checking that all the results are the expected ones, it is trivial to turn this into Jest code. For example, tests for straights become the following:
describe("Common Straights", () => {
it("may start with Ace", () => {
expect(handRank(["3♥", "2♦", "A♣", "5♥", "4♠"])).toBe(
Hand.Straight
);
});
it("may end with Ace", () => {
expect(handRank(["J♥", "10♦", "A♣", "Q♥", "K♠"])).toBe(
Hand.Straight
);
});
it("may be all middle cards", () => {
expect(handRank(["3♥", "4♦", "7♣", "5♥", "6♠"])).toBe(
Hand.Straight
);
});
});
I won’t show all the other tests because they are all like these; call handRank()
with a set of cards, and verify that the right rank is returned.
Enhancing the code
Let’s consider some enhancements to the code — they are meant as exercises for you to better grasp the logic we wrote.
- Return information on the hand, that can be used for comparisons. Ideally, you would have both a string description (like a Full House could be “Fives over Kings”, and a Pair could be “Pair of Jacks”) and a numeric value used for comparisons; the higher, the better.
- Add validation for cards. Our
rankToNumber()
andsuitToNumber()
functions assume valid cards; a safer implementation would add checks to ensure no wrong values come in. - Allow for variants of the game, like including jokers that can represent any card you wish (wild cards) or playing with more cards like in Stud Poker or Community Card Poker.
- Finally, for a different game, make the code work for Poker Dice; you will have to add a new “Five of a Kind” result, drop the suit-based results like Flush or Royal Flush, and produce a different hand ranking.
Conclusion
In this article, we produced an interesting pattern-matching algorithm for poker hands, and we implemented it in TypeScript with full data typing. If you want to program your own poker-playing program, this hand-detection logic will be at the heart of your code; get started!