Sui.

Post

Share your knowledge.

Bounty+10

Peera Admin.
May 29, 2025
Expert Q&A

Why does BCS require exact field order for deserialization when Move structs have named fields?

Why does BCS require exact field order for deserialization when Move structs have named fields? I've been diving deep into BCS encoding/decoding in Move, particularly for cross-chain communication and off-chain data processing. While working through the examples in the Sui Move documentation, I encountered some behavior that seems counterintuitive and I'm trying to understand the underlying design decisions.

According to the BCS specification, "there are no structs in BCS (since there are no types); the struct simply defines the order in which fields are serialized." This means when deserializing, we must use peel_* functions in the exact same order as the struct field definition.

My Specific Questions:

  1. Design Rationale: Why does BCS require exact field order matching when Move structs have named fields? Wouldn't it be more robust to serialize field names alongside values, similar to JSON or other self-describing formats?
  2. Generic Type Interaction: The docs mention that "types containing generic type fields can be parsed up to the first generic type field." Consider this structure:

struct ComplexObject<T, U> has drop, copy {
    id: ID,
    owner: address,
    metadata: Metadata,
    generic_data: T,
    more_metadata: String,
    another_generic: U
}

How exactly does partial deserialization work here? Can I deserialize up to more_metadata and ignore both generic fields, or does the first generic field (generic_data) completely block further deserialization? 4. Cross-Language Consistency: When using the @mysten/bcs JavaScript library to serialize data that will be consumed by Move contracts, what happens if:

  • I accidentally reorder fields in the JavaScript object?
  • The Move struct definition changes field order in a contract upgrade?
  • I have nested structs with their own generic parameters?
  1. Practical Implications: In production systems, how do teams handle BCS schema evolution? Do you version your BCS schemas, or is the expectation that struct field order is immutable once deployed?
  • Sui
  • Move
5
3
Share
Comments
.

Answers

3
0xduckmove.
May 31 2025, 15:14

Binary Canonical Serialization (BCS) in Move

Here are my answers for your questions

Why Field Order Must Match Exactly

Imagine you have a Move struct defined like this:

module example::ordering {
    struct MyStruct has copy, drop, store {
        a: u64,
        b: bool,
    }

    public fun to_bytes_example(): vector<u8> {
        let instance = MyStruct { a: 42, b: true };
        bcs::to_bytes(&instance)
    }

    public fun from_bytes_example(bytes: vector<u8>): MyStruct {
        bcs::from_bytes(&bytes)
    }
}

You all know that serializing MyStruct { a: 42, b: true } produces eight bytes for the u64 42 (in little-endian) followed by one byte for true (for example, 0x01). Now, if you ever change the struct to:

public struct MyStruct has copy, drop, store {
    b: bool,
    a: u64,
}

the serialized bytes would start with one byte for the boolean, then eight bytes for the integer. Yet the deserializer in Move is still going to attempt to read the first eight bytes as a u64 and the next byte as a bool.

Because BCS never recorded “this was a bool first,” Move ends up interpreting leftover data as a wrong number or failing outright. In short, swapping fields breaks everything.


How Generics Affect Partial Deserialization

Can I skip over them if I only care about some later fields

The answer is: not unless you know exactly how to parse those generics. Picture this Move struct:

module example::generics {
    use std::string::String;

    public struct ComplexObject<T, U> has copy, drop, store {
        id: ID,
        owner: address,
        metadata: vector<u8>,
        generic_data: T,
        more_metadata: String,
        another_generic: U,
    }

    public fun partial_deserialize(bytes: vector<u8>): String {
        let obj: ComplexObject<Signer, Signer> = bcs::from_bytes(&bytes);
        obj.more_metadata
    }
}


When you call bcs::from_bytes::<ComplexObject<Signer, Signer>>(&bytes), the parser knows that T is Signer, which in Sui is a fixed-size 32-byte address. It reads 32 bytes, figures out that’s your generic_data, then it moves on to read “more_metadata: String” (which is length-prefixed) and finally another_generic: U.

But if you haven’t specified T or if T were something like vector whose length isn’t known ahead of time there’s no way to find out where more_metadata starts.

BCS has no built-in length markers for arbitrary fields; you must decode generic_data fully before you can even think about more_metadata. In other words, partial deserialization stops at the first generic field unless you already know its exact type.


Keeping JavaScript and Move in Sync with @mysten/bcs

When you serialize in JavaScript with @mysten/bcs, you must register your struct using the same field order exactly as Move. Otherwise, Move’s bcs::from_bytes will misinterpret the bytes. Here’s what that looks like:

import { Bcs, u64, bool } from "@mysten/bcs";

const bcs = new Bcs();

// Match Move’s struct: struct MyStruct { a: u64, b: bool }
bcs.registerStructType("MyStruct", {
  a: u64(),
  b: bool(),
});

// If you accidentally do this instead:
// bcs.registerStructType("MyStruct", {
//   b: bool(),
//   a: u64(),
// });

const instance = { a: 42n, b: true };
const bytes = bcs.ser("MyStruct", instance);

Since Move expects [8 bytes of u64][1 byte of bool], if JavaScript emits [1 byte of bool][8 bytes of u64], Move’s parser tries to read eight bytes for the u64 but sees garbage. That mismatch leads to a decoding error or corrupted data.

Now, suppose you later try to upgrade that Move struct:

module upgrade::v1 {
    struct MyStruct has copy, drop, store {
        a: u64,
        b: bool,
    }
}

module upgrade::v2 {
    struct MyStruct has copy, drop, store {
        b: bool,
        a: u64,
    }
}

Once you deploy upgrade::v1, JS frontends start serializing as [u64][bool]. If you push an upgrade that swaps those fields, new calls in Move will expect [bool][u64]. All of a sudden, the old bytes are nonsense under v2, and any new serialization breaks old clients.

This is exactly why Move’s upgrade policies forbid changing struct field order in a “compatible” upgrade. If you absolutely must alter the layout, you publish a new struct (e.g. MyStructV2) and gradually migrate data.

Nested or generic structs are the same story:

module nest::demo {
    struct Inner has copy, drop, store {
        x: u64,
        y: vector<u8>,
    }

    struct Outer<T> has copy, drop, store {
        id: u64,
        payload: T,
        note: vector<u8>,
    }
}

On the JavaScript side, you’d do something like:

import { Bcs, u64, vector, Struct } from "@mysten/bcs";

const bcs = new Bcs();

// Register Inner exactly as Move defined it
bcs.registerStructType("Inner", {
  x: u64(),
  y: vector(u8()),
});

// Register Outer<Inner>, matching Move’s field order
bcs.registerStructType("Outer<Inner>", {
  id: u64(),
  payload: new Struct("Inner"),
  note: vector(u8()),
});

When JS serializes Outer, it writes the 8 bytes of id; then it recurses into Inner, writing 8 bytes for x and a length-prefixed y; then it writes note. Move’s bcs::from_bytes<Outer> does the same steps.

If any part of that nested schema doesn’t match, you used vector() instead of vector() everything breaks, because the length prefixes and element sizes will differ.

For the final question

In a production Move-based system, once you’ve published a struct on-chain (and especially once clients have started reading and writing BCS data), that struct’s layout is effectively immutable. Both Aptos and Sui require that compatible upgrades must leave existing struct field layouts untouched. If you try to reorder, insert, or delete fields in a published struct, the upgrade is considered incompatible and typically blocked by governance or by release tooling.

When you do need schema changes, most teams take one of these paths:

First, you might embed a small version: u8 field at the front of your struct so readers can see a version number and branch accordingly:

public struct DataV1 has copy, drop, store {
    version: u8,       // 1
    field_a: u64,
    field_b: bool,
}

public struct DataV2 has copy, drop, store {
    version: u8,       // 2
    field_a: u64,
    field_b: bool,
    field_c: vector<u8>, // new field in version 2
}

Here, any consumer can read the first byte; if it’s 1, they parse with DataV1. If it’s 2, they parse with DataV2. The downside is old clients that only know about DataV1 may choke on version 2 unless you write special fallback logic.

Another approach is to publish a new struct entirely, such as MyStructV2, leaving MyStructV1 unchanged. You migrate on-chain data via a transaction that reads V1 and writes the new V2. All old code that still understands only MyStructV1 continues to work until you decide to deprecate it.

On Sui, you also have dynamic fields for more flexibility. Instead of tacking on a new field to an existing struct, you store additional data as a separate child object keyed by the original object’s ID. Because the base struct’s BCS layout never changes, clients reading it still see the original fields. Then, whenever they need to read or write the extra data, they know to query the dynamic child. Dynamic fields are a powerful way to extend object schemas without ever touching the original struct layout.

Some teams reserve padding fields:


struct MyStruct has copy, drop, store {
    field_a: u64,
    reserved_1: u64, // unused for now
    field_b: bool,
}

4
Best Answer
Comments
.
Owen.
Owen212
May 31 2025, 04:13

1. Why does BCS require exact field order?

Because BCS only serializes raw values — not field names or type metadata.

Move structs have named fields, but BCS treats them as ordered tuples. Field names are compile-time information and are not included in the serialized output.

Example:

struct MyStruct {
    a: u64,
    b: bool,
}
  • Serialized as: [u64 bytes] + [bool bytes]
  • Deserializer must read u64 then boolorder matters

This makes BCS:

  • Fast and compact
  • Not self-describing or schema-flexible

2. How does partial deserialization work with generic types?

Given:

struct ComplexObject<T, U> {
    id: ID,
    owner: address,
    metadata: Metadata,
    generic_data: T,      // <-- generic
    more_metadata: String,
    another_generic: U,   // <-- generic
}

Rule:

You can deserialize up to the first generic field (generic_data). After that, parsing stops unless you know how to decode T.

So:

  • If you don’t know how to parse T, you cannot safely reach more_metadata or another_generic.
  • You must use low-level peel_* functions carefully if you want partial access.

Only non-generic prefix can be safely parsed without full type knowledge.


3. Cross-language consistency with JavaScript (@mysten/bcs)

  • Field order must match exactly, just like in Move.
  • Reordering fields in JS object → deserialization error or misinterpreted data
  • Changing Move struct field order after deployment → breaks compatibility
  • Nested structs with generics → works only if all nested schemas are registered correctly

4. How do teams handle BCS schema evolution in production?

Common strategies:

  • Schema versioning: Attach version number to serialized data
  • Immutable structs: Once deployed, never reorder or remove fields
  • Avoid reordering, renaming, or removing fields post-deployment
  • Use reserved/padding fields to allow future expansion
  • Introduce new structs instead of modifying old ones
  • Prefer concrete types in cross-language structs
3
Comments
.
md rifat hossen.
Jun 19 2025, 17:17

My Answer (for +10 Bounty):

BCS (Binary Canonical Serialization) requires exact field order because it doesn’t store field names — only raw binary values are serialized. So the deserializer must read fields in the same order as defined in the Move struct. If the order is changed, even slightly, it will misinterpret the data or throw an error.

Example:

struct MyStruct { a: u64, b: bool, }

Serialized output is [8 bytes of a][1 byte of b]. If you reverse the order, the bytes will be interpreted incorrectly.

🔍 Why not include field names (like JSON)?

Because BCS is designed for performance — it is compact and fast. Including field names or type info would make it slower and heavier, which is not ideal for blockchain performance.


🧬 Generics: Why parsing stops

If your struct has generics:

struct ComplexObject<T, U> { id: ID, generic_data: T, metadata: String, }

You can’t parse metadata unless you know exactly what T is. Because BCS has no built-in way to skip over unknown types.


⚠️ In JavaScript (@mysten/bcs):

When using bcs.registerStructType, you must match field order exactly as in Move. Otherwise:

Deserialization will fail

Or data will be misread

Never change field order in a published Move struct! Use versioning or a new struct (e.g., MyStructV2) if needed.


✅ Summary:

Field order matters because BCS is not self-describing

Can’t deserialize past unknown generic types

Always match struct layouts across Move and frontend

Use schema versioning or dynamic fields for upgrades


Submitted by: md rifat hossen Feel free to paste this as your answer in Peera → it should help you earn the bounty.

0
Comments
.

Do you know the answer?

Please log in and share it.

Sui is a Layer 1 protocol blockchain designed as the first internet-scale programmable blockchain platform.

328Posts475Answers
Sui.X.Peera.

Earn Your Share of 1000 Sui

Gain Reputation Points & Get Rewards for Helping the Sui Community Grow.

Reward CampaignJune