Post
Share your knowledge.
+15
How do ability constraints interact with dynamic fields in heterogeneous collections?
I'm building a marketplace that needs to handle multiple asset types with different ability requirements, and I've hit some fundamental questions about Move's type system. I want to store different asset types in the same collection, but they have different abilities:
- Regular NFTs:
key + store
(transferable) - Soulbound tokens:
key
only (non-transferable) - Custom assets with transfer restrictions
public struct Marketplace has key {
id: UID,
listings: Bag, // Want to store different asset types here
}
// This works for transferable assets
public fun list_transferable<T: key + store>(
marketplace: &mut Marketplace,
asset: T,
price: u64
) { /* ... */ }
// But how to handle soulbound assets?
public fun list_soulbound<T: key>( // No store ability
marketplace: &mut Marketplace,
asset_ref: &T, // Can only take reference
price: u64
) { /* How do I store metadata about this? */ }
Key Questions:
- Ability Requirements: When using
dynamic_field::add<K, V>()
, doesV
always needstore
at compile time? Can wrapper types work around this? - Heterogeneous Storage: Can a single Bag store objects with different ability sets (
key + store + copy
vskey + store
), and handle them differently at runtime? - Type Safety: Since dynamic fields perform type erasure, how do I maintain type safety when retrieving values? What's the pattern for storing type metadata?
- Witness Pattern: How do ability constraints work with phantom types? Can I store
Asset<Type1>
andAsset<Type2>
in the same collection and extract type info later?
Building a system where NFTs, soulbound tokens, and restricted assets all need marketplace functionality but with different transfer semantics.
I’ve tried wrapper types, multiple collections per ability set, separate type metadata storage. Each has tradeoffs between type safety, gas costs, and complexity.
- Sui
- Architecture
Answers
5You’re running into the core of Move’s type and ability system and you’re absolutely right to raise these questions when designing a heterogeneous marketplace.
First, about ability constraints: any value you store using dynamic_field::add<K, V>() must have the store ability at compile time. This is a hard rule in Move: values without store simply can’t be persisted in storage, whether in dynamic fields, Tables, or similar constructs. Trying to wrap a non-store type in a struct won’t work either—Move is strict here. If even one field lacks store, the entire wrapper loses store too. So unfortunately, there’s no wrapper workaround here. Soulbound assets with only key (and no store) are excluded from any collection that writes to global storage like a Bag or dynamic field. This is well documented in the Move abilities docs.
As for storing different asset types in the same collection: if they all implement key + store, then yes, a single Bag can store them, but under the hood, Move doesn’t do real polymorphism. The type must be known at access time, and the Bag must be homogeneous from the compiler’s perspective. This leads to your next question: how do we maintain type safety in dynamic fields? Since Move does type erasure for these collections, it won’t remember what exact type is inside—you need to provide the mapping. One common pattern is to wrap each asset in a struct that includes metadata like a u8 enum or a TypeName, so when you retrieve the object, you inspect the metadata and downcast or handle accordingly. There’s no magic RTTI; you fake it with data discipline.
Now about phantom types and witness patterns—these are helpful for compile-time safety but don’t give you runtime introspection. So yes, you can create Asset
In your marketplace design, this means you’re likely looking at a hybrid strategy. For regular NFTs and other transferable assets, you can use dynamic object fields or Bags freely, as long as they conform to key + store. For soulbound assets, which are key only, your best bet is to store references and associated metadata in a parallel metadata Bag, or use a custom listing type that doesn’t store the asset itself but records its existence via ID and supplementary data. It’s more complex, but it gives you the flexibility to enforce transfer semantics properly.
References
1. Do values in dynamic fields always need store
?
If you want to store the value directly in a dynamic field (e.g., via dynamic_field::add
), it must satisfy all the abilities required by the field's type — including store
. If V
does not have store
, you cannot put it into a dynamic field directly.
But here's the trick:
You can wrap such types inside another struct that has
store
, even if the inner type doesn't.
Example:
struct AssetWrapper<T: key> has store {
inner: T,
}
Even though T
only has key
, AssetWrapper<T>
can be stored because it itself has store
.
Now you can do:
dynamic_field::add(&mut object, key, AssetWrapper { inner: soulbound_token });
This allows you to store both transferable and non-transferable assets under the same abstraction.
2. Can a single Bag store objects with different ability sets?
The short answer is: yes, using wrappers and type erasure techniques. But there are tradeoffs.
In Move, you can’t directly have a list of multiple concrete types unless they’re wrapped in a generic container.
A good approach is to use a wrapper like:
struct AnyAsset has store {
// Maybe include metadata or witness type info
data: Vector<u8>, // serialized representation?
type_info: TypeInfo, // Type metadata
}
Or better yet, use a variant-style enum encoding (if supported by your Move dialect):
enum AssetType has store {
Transferable(NFT),
Soulbound(SoulboundToken),
Restricted(RestrictedAsset),
}
However, this requires exhaustive matching and doesn't scale well across modules.
So, again: wrapping + dynamic fields + type metadata is more flexible.
3. How to maintain type safety when retrieving?
Move performs type erasure in dynamic fields. So once you retrieve a value from a dynamic field, you just get a value of some type V
— but you don't know what V
is at runtime.
To solve this safely, you need to:
- Store type metadata alongside the value.
- Use a witness pattern or type tag embedding to reassert type information upon retrieval.
Pattern: Store type metadata
struct AssetWithMetadata<T: key> has store {
value: T,
type_info: TypeInfo,
}
Then store AssetWithMetadata<NFT>
or AssetWithMetadata<SoulboundToken>
in a dynamic field.
When you retrieve, check the type_info
against the expected type before doing anything unsafe.
4. Witness Pattern & Phantom Types
You can absolutely use phantom types as witnesses to encode behavior, like transferability.
For example:
struct Transferable; // marker type
struct NonTransferable; // marker type
struct Asset<T: drop + copy + store + key, Policy: phantom> has key, store {
id: UID,
policy: PhantomData<Policy>,
}
Then:
type NFT = Asset<_, Transferable>;
type Soulbound = Asset<_, NonTransferable>;
You can then write logic like:
public fun transfer<T>(asset: Asset<T, Transferable>) {
// allowed
}
public fun transfer<T>(_asset: Asset<T, NonTransferable>) {
// disallowed
abort();
}
This gives you compile-time enforcement of transfer rules.
But to store both types in the same collection, you’ll still need a wrapper:
struct AnyAsset has store {
data: Vector<u8>,
type_info: TypeInfo,
}
And handle deserialization dynamically based on type_info
.
Here’s how you can design a Sui Move marketplace that supports both transferable (NFTs, key + store) and soulbound (key only) assets, while maintaining type safety and handling ability constraints.
Ability Requirements for Dynamic Fields
Yes, V must have store at compile time for dynamic_field::add<K, V>() and similar collections.
- store is required for any value to be stored in global storage, including dynamic fields and Tables.
- Wrapper types cannot work around this: If you wrap a type without store in a struct, the wrapper cannot have store either, because all fields must have store for the struct to have store (source).
Heterogeneous Storage in a Bag
- Bag (or any dynamic field-based collection) can only store types that satisfy the required abilities.
- For dynamic object fields, the value must have key + store.
- For dynamic fields, the value must have store.
- You cannot store a type with only key (e.g., a soulbound token) in a Bag that requires store.
- This is a fundamental restriction of the Move type system (source).
Type Safety and Type Erasure
- Dynamic fields are type-erased at runtime. You must know the type you want to retrieve at access time.
- Pattern for type safety:
- Store type metadata (e.g., an enum or TypeName) alongside the value.
- Use a wrapper struct that includes both the asset and its type info.
- When retrieving, check the metadata before casting.
- Example pattern:
- Store Listing { asset_type: u8, ... } and use asset_type to determine how to handle the asset.
Witness Pattern and Phantom Types
- Phantom types and the witness pattern help at compile time, not runtime.
- You can use Asset
for different types, but you still need to ensure all T have the required abilities for the collection. - At runtime, you cannot extract type info from a phantom type; you must store explicit metadata (source).
- You can use Asset
- Marketplace Design for Mixed Asset Types
-
Transferable assets (NFTs): Use key + store, store in dynamic object fields or Bags.
-
Soulbound tokens (key only): Cannot be stored in dynamic fields or Bags that require store. You must handle them separately, e.g., by storing only metadata or references (not the object itself).
-
Custom assets with restrictions: As long as they have key + store, you can store them. If not, you need a separate handling path.
-
All values in dynamic fields or Bags must have store (and key for dynamic object fields).
-
You cannot store types with only key in these collections.
-
Type safety at runtime requires explicit metadata.
-
Phantom types/witness pattern help at compile time, not runtime.
Thank you Xavier.eth for this thoughtful and technical question. Here's my contribution regarding ability constraints and dynamic fields in Sui Move:
✅ Yes, any value (V
) used in dynamic_field::add<K, V>()
must have store
ability.
This is strictly enforced in Move’s compile-time checks. Even wrapping a non-store value inside a struct won't help—if any field lacks store
, the whole struct loses it too.
✅ Storing heterogeneous assets with different abilities is possible, but only under the constraint that all assets in the dynamic field/Bags must satisfy the same ability set (typically key + store
).
🔒 Soulbound assets with only key
ability can't be stored directly in such collections. Instead, you can:
- Store only metadata (ID, type enum, etc.)
- Use a parallel mapping like
dynamic_field::add<AssetID, Metadata>
for soulbound types. - Keep a reference-based registry that tracks soulbound asset listings without storing the full object.
🧩 Phantom types help at compile time only. You’ll still need explicit runtime type metadata for actual retrieval logic.
🚀 Suggested hybrid architecture:
Bag<AssetID, TransferableAsset>
for assets withstore
Map<AssetID, SoulboundMetadata>
for key-only typesAssetWrapper
struct withtype_id
oru8 asset_type
for identifying type at runtime
Thanks again! I look forward to seeing this marketplace evolve!
— md rifat hossen
If you're building a marketplace that supports multiple asset types with different abilities in Sui Move, the key challenge is how Move's ability constraints interact with dynamic fields and heterogeneous data. You can't directly store values without the store
ability, so yes—when you use dynamic_field::add<K, V>()
, the value V
must have the store
ability at compile time. This means you can’t directly store something like a soulbound token unless it’s wrapped in a type that has store
. To work around this, you can use wrapper structs that hold metadata about the asset or a reference ID while satisfying the required abilities. For example, a SoulboundListing<T: key>
wrapper can contain just a reference or metadata, not the full object. For heterogeneous storage, a Bag
or Table
can only store one type per instantiation, but you can make it work by wrapping assets in an enum-like custom type or using trait-like phantom types to simulate type information. You won't get native polymorphism, but you can track asset types with manual tagging or use the witness pattern, where phantom types and runtime discriminants help you simulate type-level separation. If you're using Asset<T>
for each asset type, and both T1
and T2
are phantom, then yes—you can store Asset<T1>
and Asset<T2>
in a shared container by type-erasing to a common wrapper. You'll then need to store type info separately (like a type_id
field) to correctly match when reading. It’s a balancing act between type safety, gas efficiency, and design complexity. Using separate collections per asset type is the cleanest for safety, but wrapper types and indexed metadata are more scalable when you want flexibility. You’ll need to choose based on whether gas cost or developer simplicity matters more for your use case.
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.
- Why does BCS require exact field order for deserialization when Move structs have named fields?53
- Multiple Source Verification Errors" in Sui Move Module Publications - Automated Error Resolution43
- Sui Transaction Failing: Objects Reserved for Another Transaction25
- How do ability constraints interact with dynamic fields in heterogeneous collections?05