Forever Functional: Poker and TypeScript
Published: 2023-07-08 23:16:08

by [Federico Kereki](https://blog.openreplay.com/authors/federico-kereki)

<blockquote><em>[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.</em></blockquote>

![-](https://blog.openreplay.com/images/forever-functional-poker-and-typescript/images/image01.png)

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](https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#union-types).

```typescript
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.

```typescript
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;
```
![-](https://blog.openreplay.com/images/forever-functional-poker-and-typescript/images/image02.png)


These types are for internal work; we must also define the type of the result of our rank-detection algorithm. We need an [enum](https://www.typescriptlang.org/docs/handbook/enums.html) type for the possible values of a hand. The values are ordered from lowest ("high card") to highest ("royal flush").

```typescript
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](https://www.typescriptlang.org/docs/handbook/2/objects.html#tuple-types) of five cards and return a `Hand` result.

```typescript
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.

```typescript
 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...
```

![-](https://blog.openreplay.com/images/forever-functional-poker-and-typescript/images/image03.png)


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.

```typescript
 // ...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.

```typescript!
 // ...continued
 .
 .
 .
 countByRank[14] = countByRank[1];
 .
 .
 .
 // continues...
```

Now we can start recognizing hands. We need only look at counts by rank to recognize several hands:

```typescript
 // ...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"](https://en.wikipedia.org/wiki/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.

```typescript
 // ...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.

![-](https://blog.openreplay.com/images/forever-functional-poker-and-typescript/images/image04.png)

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:

```typescript
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!'
   );
 }
}
```

<div style="background-color:#efefef; border-radius:8px; padding:10px; display:block;">
<hr/>
<h3><em>Session Replay for Developers</em></h3> 
<p><em>Uncover frustrations, understand bugs and fix slowdowns like never before with <strong><a href="https://github.com/openreplay/openreplay" target="_blank">OpenReplay</a></strong> — an open-source session replay suite for developers. It can be <strong>self-hosted</strong> in minutes, giving you complete control over your customer data.</em></p>
<img alt="OpenReplay" style="margin-top:5px; margin-bottom:5px;" width="768" height="400" src="https://raw.githubusercontent.com/openreplay/openreplay/main/static/openreplay-git-hero.svg" class="astro-UXNKDZ4E" loading="lazy" decoding="async">
<p><em>Happy debugging! <a href="https://openreplay.com" target="_blank">Try using OpenReplay today.</a></em><p>
<hr/>
</div>



## 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.

```typescript
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
```

![-](https://blog.openreplay.com/images/forever-functional-poker-and-typescript/images/image05.png)


After visually checking that all the results are the expected ones, it is trivial to turn this into [Jest](https://jestjs.io/) code. For example, tests for straights become the following:

```typescript
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()` 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](https://en.wikipedia.org/wiki/Joker_(playing_card)) that can represent any card you wish (wild cards) or playing with more cards like in [Stud Poker](https://en.wikipedia.org/wiki/Stud_poker) or [Community Card Poker](https://en.wikipedia.org/wiki/Community_card_poker).

![-](https://blog.openreplay.com/images/forever-functional-poker-and-typescript/images/image06.png)

* Finally, for a different game, make the code work for [Poker Dice](https://en.wikipedia.org/wiki/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!