Building a Crossword in F# - Part II

April 18 2021 · tech fsharp fable feliz

In the first post we built a basic crossword engine in F# using Fable, Feliz, and React. In this post we’ll refactor the code to make it more idiomatic F#.

Data Types

First a recap on the data types which make up the crossword. The grid is made up of a list of list of cells, with the cells being Black or White. Black cells are blocked out spacers, whereas in White cells we must enter a guess.

type White = {
    Number: int option
    Solution: string
    Guess: string
    Solved: bool
    Id: int // uniquely identify each cell, needed by React
}

type Cell = 
    | Black
    | White of White

type Grid = Cell list list

Looking at the Problem

Function updateGuess is called every time a value is typed into a cell - it’s connected to the HTML form field’s onChange event. Given the existing application state, the cell which was updated, and the value v which was typed into the cell it returns a new game state. It doesn’t need to check whether the value was correct. It’s implemented as below:

let updateGuess state cell v =
    let newGrid = 
        state.grid
        |> List.map (fun row -> 
            row
            |> List.map (fun c ->
                match c with
                | Black -> c
                | White whiteCell -> if whiteCell.Id = cell.Id then Cell.White {whiteCell with Guess = v} else Cell.White whiteCell
            )
        )

    { state with grid = newGrid; }

The code is OK, but for such a simple operation it would be nice if it were cleaner. Elsewhere in the code we can see the same pattern repeated. Function checkSolution is called once the user clicks the Check button. Here we

let checkSolution (state: State): State = 
    let grid = 
        state.grid
        |> List.map (fun row -> 
            row |> List.map (fun cell -> 
                    match cell with
                    | Black -> cell
                    | White c -> Cell.White (checkWhiteCell c )
            )
        )

    ...

When doing any operation with a Grid we’re really only interested in the White cells within it. In the code snippets above we iterate through the grid’s rows and columns using List.map twice, and then have to match on the Cell type so as only to perform the action on White cells.

Define map for our Data Types

Let’s define our own gridMap function. That will allow us to turn the snippets from above into

let updateGuess  updatingCell v state =
    let newGrid =
        state.grid
        |> gridMap (fun cell -> if cell.Id = updatingCell.Id then {cell with Guess = v} else cell)

    { state with grid = newGrid }


let checkCellsAndUpdateStateIfSolved (state: State): State = 
    let grid = 
        state.grid
        |> gridMap (fun c -> { c with Solved = c.Solution = c.Guess })

    { state with grid = grid }

Much cleaner!

The Implementation

We define gridMap in terms of cellMap. The operation we provide f has a function signature of White -> White. When operating on the grid we’re only interested in White cells, the Black cells by definition have nothing in them.

// If we perform an operation on a cell it's always "Do something to a White cell"    
let cellMap (f: White -> White) (cell: Cell): Cell =
    match cell with
    | Black -> Black
    | White w -> White (f w)

let gridMap (f: White -> White) (grid: Grid): Grid =    
    grid |> List.map (List.map (cellMap f))

Conclusion

Next up we’ll look at defining crosswords as JSON files and being able to load them dynamically into the engine.

Full source code is available on Github.


Related Posts