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.