DEV Community

Cover image for Forever Functional: Poker and TypeScript
OpenReplay Tech Blog
OpenReplay Tech Blog

Posted on • Originally published at blog.openreplay.com

Forever Functional: Poker and TypeScript

by Federico Kereki

[Poker](https://en.wikipedia.org/wiki/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 = '' | '' | '' | '';
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

-

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
}
Enter fullscreen mode Exit fullscreen mode

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 {
 .
 .
 .
}
Enter fullscreen mode Exit fullscreen mode

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...
Enter fullscreen mode Exit fullscreen mode

-

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...
Enter fullscreen mode Exit fullscreen mode

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...
Enter fullscreen mode Exit fullscreen mode

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...
Enter fullscreen mode Exit fullscreen mode

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!'
   );
 }
Enter fullscreen mode Exit fullscreen mode

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!'
   );
 }
}
Enter fullscreen mode Exit fullscreen mode

Session Replay for Developers

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — an open-source session replay suite for developers. It can be self-hosted in minutes, giving you complete control over your customer data.

OpenReplay

Happy debugging! Try using OpenReplay today.


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
Enter fullscreen mode Exit fullscreen mode

-

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
   );
 });
});
Enter fullscreen mode Exit fullscreen mode

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() and suitToNumber() 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!

Top comments (0)