DEV Community

Cover image for Type-Safe Rust ↔ TypeScript Communication for Solana
RECTOR SOL for LUMOS

Posted on • Originally published at docs.lumos-lang.org

Type-Safe Rust ↔ TypeScript Communication for Solana

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,
}
Enter fullscreen mode Exit fullscreen mode

Then manually recreate it in TypeScript:

export interface PlayerAccount {
  wallet: PublicKey;
  level: number;
  experience: number;
}
Enter fullscreen mode Exit fullscreen mode

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,
}
Enter fullscreen mode Exit fullscreen mode

Generate:

lumos generate schema.lumos
Enter fullscreen mode Exit fullscreen mode

Complete Workflow

┌─────────────────┐     ┌──────────────────┐     ┌─────────────────────┐
│   schema.lumos  │────▶│  lumos generate  │────▶│  generated.rs/.ts   │
└─────────────────┘     └──────────────────┘     └─────────────────────┘
                                                          │
                        ┌─────────────────────────────────┼─────────────────────────────────┐
                        │                                 │                                 │
                        ▼                                 ▼                                 ▼
              ┌─────────────────┐              ┌─────────────────┐              ┌─────────────────┐
              │  Rust Program   │◀────────────▶│  Solana RPC     │◀────────────▶│  TypeScript     │
              │  (Anchor/Borsh) │              │  (On-chain)     │              │  Client         │
              └─────────────────┘              └─────────────────┘              └─────────────────┘
Enter fullscreen mode Exit fullscreen mode

Rust Program Usage

use anchor_lang::prelude::*;

mod generated;
use generated::PlayerAccount;

#[program]
pub mod game_program {
    use super::*;

    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(())
    }

    pub fn add_experience(ctx: Context<UpdatePlayer>, amount: u64) -> Result<()> {
        let player = &mut ctx.accounts.player;
        player.experience = player.experience.checked_add(amount).unwrap();
        player.last_active = Clock::get()?.unix_timestamp;
        Ok(())
    }
}
Enter fullscreen mode Exit fullscreen mode

TypeScript Client Usage

Fetch Account Data

import { Connection, PublicKey } from '@solana/web3.js';
import { PlayerAccount, PlayerAccountBorshSchema } from './generated';

async function getPlayerAccount(
  connection: Connection,
  playerPubkey: PublicKey
): Promise<PlayerAccount> {
  const accountInfo = await connection.getAccountInfo(playerPubkey);

  if (!accountInfo) {
    throw new Error('Player account not found');
  }

  // Skip 8-byte discriminator for Anchor accounts
  const data = accountInfo.data.slice(8);
  return PlayerAccountBorshSchema.decode(data);
}
Enter fullscreen mode Exit fullscreen mode

Subscribe to Updates

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

// Usage
const subscriptionId = subscribeToPlayer(connection, playerPubkey, (player) => {
  console.log(`Level: ${player.level}, XP: ${player.experience}`);
});
Enter fullscreen mode Exit fullscreen mode

Build Transactions

import * as anchor from '@coral-xyz/anchor';

async function addExperience(
  program: anchor.Program,
  playerPubkey: PublicKey,
  amount: number
): Promise<string> {
  const tx = await program.methods
    .addExperience(new anchor.BN(amount))
    .accounts({ player: playerPubkey })
    .rpc();

  console.log(`Added ${amount} XP. TX: ${tx}`);
  return tx;
}
Enter fullscreen mode Exit fullscreen mode

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
[T] Vec<T> T[]
Option<T> Option<T> `T \

Benefits

Single Source of Truth - Define types once
Guaranteed Sync - Rust and TypeScript always match
Correct Borsh - Field order and sizes guaranteed
Zero Runtime Overhead - Generated code is identical to manual
IDE Support - Full TypeScript autocomplete


Get Started

{% raw %}

cargo install lumos-cli
lumos generate schema.lumos
Enter fullscreen mode Exit fullscreen mode

Questions? Drop them below!

Top comments (0)