Opening the wasm-bindgen Boundary

Build a Chess Game with Rust and WebAssembly

The crate can play chess; JavaScript still can’t see most of it. This lesson exports the full surface and settles the design question that shapes every later lesson: what types cross the boundary? The answer is strings, booleans, and nothing else. Checkpoint: lesson-04.

The wrapper pattern

Lesson 3’s try_* methods return Result<_, String>. JavaScript wants a thrown exception. The entire translation layer is this:

pub fn make_move(&mut self, uci: &str) -> Result<String, JsValue> {
    self.try_make_move(uci).map_err(|e| JsValue::from_str(&e))
}

pub fn set_fen(&mut self, fen: &str) -> Result<(), JsValue> {
    self.try_set_fen(fen).map_err(|e| JsValue::from_str(&e))
}

When a #[wasm_bindgen] method returns Result<_, JsValue>, the generated glue throws the error value. So game.make_move('e5') from TypeScript lands in a catch block holding the string "Invalid move". JsValue appears in exactly these map_err calls and nowhere else in the crate — grep for it later if you suspect the boundary is leaking inward.

The read surface

The rest of the exported impl is getters, and each one answers a question some future lesson asks. The complete surface, and who consumes it:

Export Returns Consumed by
get_fen() full FEN string rendering (6), persistence (9)
get_moves_from("e2") "e3,e4" click and hover highlights (7)
get_legal_moves() every move, comma-joined tests
is_human_turn / is_check / is_game_over bool turn gating (7), status bar (8)
get_result() "ongoing", "1-0", "0-1", "1/2-1/2" the game-over banner (8)
get_history() / set_history(s) ", "-joined SAN history panel and saves (9)
get_turn() "white" / "black" tests, check highlight (8)
get_last_move() "e2e4" or "" last-move marks (7), animation (13)
reset() the New Game button (9)

Two of these deserve their comment blocks copied verbatim. get_fen serializes with EnPassantMode::Legal so the complete position round trips, en passant rights included. And get_last_move documents a quirk: for castling, shakmaty’s “to” square is the rook’s square, not the king’s destination — remember that when lesson 13 animates the wrong piece and you wonder why it doesn’t.

Formats stay primitive on purpose. "e3,e4" costs a split(',') on the other side, which is trivial; a serde setup with mirrored types costs a dependency, generated bindings, and a schema to keep in sync. For a boundary this small, strings win.

Reading the generated contract

Rebuild and open chess-engine/pkg/chess_engine.d.ts. Every method you exported is there with real TypeScript types — Result<String, JsValue> became a string return that throws. Two details to notice while you’re in the file: the class has a free() method (wasm objects are manual memory; this app creates exactly one ChessGame for the life of the page and never frees it, a fine decision at n=1), and the module’s default export is an init function — lesson 5 is about calling it.

Three new tests cover the new reads (get_moves_from against known squares, twenty legal moves at the start, history set/reset), and the lesson-2 and lesson-3 tests grow asserts for the getters that now exist.

Build it

File Action What goes in it
chess-engine/src/lib.rs modify make_move/set_fen wrappers; the getters table above; three new tests; extend new_game_starts_at_initial_position and makes_san_moves with the new asserts
chess-engine/pkg/ generated Rebuild, delete the dropped .gitignore, commit

Done when: npm run test:rust reports 13 passed, and pkg/chess_engine.d.ts lists every method in the table.

Answer key: lesson-03...lesson-04.

Challenge: expose the move number

Add a get_move_number() export returning shakmaty’s fullmove counter, with a test proving it reads 1 at the start and 2 after e4 e5 Nf3. Small, but it walks the whole path: plain method, export, rebuild, check the .d.ts picked it up.

Next lesson, the two halves finally meet: Vite learns to serve the wasm, and the engine prints a chessboard into a browser that has no UI yet.