Type-Safe Rust ↔ TypeScript Communication for Solana
Source: Dev.to

Building a Solana dApp with both on‑chain Rust and off‑chain TypeScript?
Here’s how to keep your types perfectly synchronized.
The Problem
You define a struct in Rust:
#[account]
pub struct PlayerAccount {
pub wallet: Pubkey,
pub level: u16,
pub experience: u64,
}
Then manually recreate it in TypeScript:
export interface PlayerAccount {
wallet: PublicKey;
level: number;
experience: number;
}
What could go wrong?
- Field order mismatch → deserialization fails
- Type‑size differences → corrupted data
- Forgotten updates → runtime errors
The Solution: LUMOS
Define once, generate both:
// schema.lumos
#[solana]
#[account]
struct PlayerAccount {
wallet: PublicKey,
level: u16,
experience: u64,
inventory: [String],
last_active: i64,
}
Generate the bindings:
lumos generate schema.lumos
Complete Workflow
flowchart LR
A[schema.lumos] --> B[lumos generate]
B --> C[generated.rs / generated.ts]
C --> D[Rust Program (Anchor/Borsh)]
C --> E[Solana RPC (On‑chain)]
C --> F[TypeScript Client]
D <--> E
E <--> F
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────────┐
│ schema.lumos │────▶│ lumos generate │────▶│ generated.rs/.ts │
└─────────────────┘ └──────────────────┘ └─────────────────────┘
│
┌─────────────────────────────────┼─────────────────────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Rust Program │◀────────────▶│ Solana RPC │◀────────────▶│ TypeScript │
│ (Anchor/Borsh) │ │ (On‑chain) │ │ Client │
└─────────────────┘ └─────────────────┘ └─────────────────┘
Rust Program Usage
use anchor_lang::prelude::*;
mod generated;
use generated::PlayerAccount;
/// The program entry point.
#[program]
pub mod game_program {
use super::*;
/// Initializes a new player account.
pub fn initialize(ctx: Context<Initialize>, wallet: Pubkey) -> Result<()> {
let player = &mut ctx.accounts.player;
player.wallet = wallet;
player.level = 1;
player.experience = 0;
player.inventory = Vec::new();
player.last_active = Clock::get()?.unix_timestamp;
Ok(())
}
/// Adds experience points to a player.
pub fn add_experience(ctx: Context<AddExperience>, amount: u64) -> Result<()> {
let player = &mut ctx.accounts.player;
player.experience = player
.experience
.checked_add(amount)
.expect("Experience overflow");
player.last_active = Clock::get()?.unix_timestamp;
Ok(())
}
}
/// Context for the `initialize` instruction.
#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(init, payer = signer, space = 8 + PlayerAccount::LEN)]
pub player: Account<'info, PlayerAccount>,
#[account(mut)]
pub signer: Signer<'info>,
pub system_program: Program<'info, System>,
}
/// Context for the `add_experience` instruction.
#[derive(Accounts)]
pub struct AddExperience<'info> {
#[account(mut, has_one = wallet)]
pub player: Account<'info, PlayerAccount>,
pub signer: Signer<'info>,
}
TypeScript Client Usage
Below are three common patterns for interacting with a Solana program from a TypeScript client:
1. Fetch Account Data
import { Connection, PublicKey } from '@solana/web3.js';
import { PlayerAccount, PlayerAccountBorshSchema } from './generated';
/**
* Retrieves a `PlayerAccount` from the blockchain.
*
* @param connection – An active Solana `Connection`.
* @param playerPubkey – The public key of the player account.
* @returns The deserialized `PlayerAccount`.
* @throws If the account does not exist.
*/
export async function getPlayerAccount(
connection: Connection,
playerPubkey: PublicKey
): Promise<PlayerAccount> {
const accountInfo = await connection.getAccountInfo(playerPubkey);
if (!accountInfo) {
throw new Error('Player account not found');
}
// Anchor‑generated accounts prepend an 8‑byte discriminator.
const data = accountInfo.data.slice(8);
return PlayerAccountBorshSchema.decode(data);
}
2. Subscribe to Updates
import { Connection, PublicKey } from '@solana/web3.js';
import { PlayerAccount, PlayerAccountBorshSchema } from './generated';
/**
* Registers a listener that fires whenever the player account changes.
*
* @param connection – An active Solana `Connection`.
* @param playerPubkey – The public key of the player account.
* @param callback – Called with the updated `PlayerAccount`.
* @returns The subscription ID (useful for later removal via `connection.removeAccountChangeListener`).
*/
export function subscribeToPlayer(
connection: Connection,
playerPubkey: PublicKey,
callback: (player: PlayerAccount) => void
): number {
return connection.onAccountChange(
playerPubkey,
(accountInfo) => {
const data = accountInfo.data.slice(8);
const player = PlayerAccountBorshSchema.decode(data);
callback(player);
},
'confirmed'
);
}
/* Example usage */
const subscriptionId = subscribeToPlayer(connection, playerPubkey, (player) => {
console.log(`Level: ${player.level}, XP: ${player.experience}`);
});
3. Build Transactions
import * as anchor from '@coral-xyz/anchor';
import { PublicKey } from '@solana/web3.js';
/**
* Calls the `addExperience` instruction on the program.
*
* @param program – The Anchor `Program` instance.
* @param playerPubkey – The player account to update.
* @param amount – Amount of experience to add.
* @returns The transaction signature.
*/
export async function addExperience(
program: anchor.Program,
playerPubkey: PublicKey,
amount: number
): Promise<string> {
const txSignature = await program.methods
.addExperience(new anchor.BN(amount))
.accounts({ player: playerPubkey })
.rpc();
console.log(`Added ${amount} XP. TX: ${txSignature}`);
return txSignature;
}
These snippets assume you have already generated the Borsh schema (PlayerAccountBorshSchema) and the Anchor IDL for your program. Adjust import paths as needed for your project structure.
Type Mapping Reference
| LUMOS | Rust | TypeScript |
|---|---|---|
u8‑u64 | u8‑u64 | number |
u128 | u128 | bigint |
i8‑i64 | i8‑i64 | number |
bool | bool | boolean |
String | String | string |
PublicKey | Pubkey | PublicKey |
Types Overview
icKey
Pubkey
PublicKey
[T]
Vec
T[]
Option
Option
T \
Benefits
- ✅ Single Source of Truth – Define types once.
- ✅ Guaranteed Sync – Rust and TypeScript always match.
- ✅ Correct Borsh – Field order and sizes are guaranteed.
- ✅ Zero Runtime Overhead – Generated code is identical to hand‑written code.
- ✅ IDE Support – Full TypeScript autocomplete.
Get Started
cargo install lumos-cli
lumos generate schema.lumos
Documentation: [Link to docs]
GitHub: [Repository URL]
Questions? Drop them below!

