Generics vs associated types in Sylvia traits design

Tomasz Kulik
Confio
Published in
5 min readApr 15, 2024

This text provides an explanation for the decision of reverting support for generic traits in Sylvia and focusing on associated types instead. Along with the explanation, a method for handling generic interfaces in a different way is described.

The article is written based on CosmWasm v2.0.0 and Sylvia v1.0.0.

Why are associated types sufficient for contract development?

The common scenario for using associated types in Sylvia interfaces is shown in the following example:

pub mod interface {
use cosmwasm_std::{Response, StdError, StdResult};
use sylvia::types::{CustomMsg, CustomQuery, ExecCtx, QueryCtx};

/// Custom query method's response
#[cosmwasm_schema::cw_serde]
pub struct ResponseStruct(pub u32);

#[sylvia::interface]
pub trait Interface {
type ExecC: CustomMsg;
type QueryC: CustomQuery + 'static;
type Error: From<StdError>;
type CustomInput: CustomMsg;

#[sv::msg(exec)]
fn execute_method(
&self,
ctx: ExecCtx<Self::QueryC>,
input: Self::CustomInput,
) -> Result<Response<Self::ExecC>, Self::Error>;

#[sv::msg(query)]
fn query_method(
&self,
ctx: QueryCtx<Self::QueryC>,
input: Self::CustomInput,
) -> StdResult<ResponseStruct>;
}
}

The following associated types were added in the example above:

  1. ExecC — Represents the type forwarded to the CosmWasm’s Response<T> type. Each contract needs to provide the concrete T. By introducing ExecC type in the interface it is possible to implement that interface on many different contracts — independently from the Response’s custom type they use.
  2. QueryC — Analogous case to the one above, but related to the Context type that is one of the arguments for the messages.
  3. Error — Every contract needs to specify a concrete error type. It should be possible for the interface above to be implemented on various contracts, no matter what their error types are.
  4. CustomInput — It is also possible to use associated types as placeholders for the types used in the methods.

The ExecC, QueryC, and Error types are special for Sylvia. These three types are treated differently and are provided solely for the purposes specified above.

An example implementation of the above interface for a contract:

use sylvia::{contract, entry_points};
use cosmwasm_std::{Empty, Response, StdError, StdResult};
use sylvia::types::{ExecCtx, InstantiateCtx, QueryCtx};

/// Some custom input type
#[cosmwasm_schema::cw_serde]
pub struct ContractInput(u32);
impl cosmwasm_std::CustomMsg for ContractInput {}

/// Custom context's Query type
#[cosmwasm_schema::cw_serde]
pub struct ContractQuery;
impl cosmwasm_std::CustomQuery for ContractQuery {}

/// Simple stateless contract type
pub struct Contract;

impl interface::Interface for Contract {
type ExecC = Empty;
type QueryC = ContractQuery;
type Error = StdError;

type CustomInput = ContractInput;

fn execute_method(
&self,
_ctx: ExecCtx<Self::QueryC>,
_input: Self::CustomInput,
) -> Result<Response, Self::Error> {
// [...]
Ok(Response::new())
}

fn query_method(
&self,
_ctx: QueryCtx<Self::QueryC>,
_input: Self::CustomInput,
) -> StdResult<interface::ResponseStruct> {
// [...]
Ok(interface::ResponseStruct(0))
}
}

#[cfg_attr(not(feature = "library"), entry_points)]
#[contract]
#[sv::messages(interface<ContractInput>)]
#[sv::custom(query=ContractQuery)]
impl Contract {
pub fn new() -> Self {
Self
}

#[sv::msg(instantiate)]
pub fn instantiate(&self, _ctx: InstantiateCtx<ContractQuery>) -> StdResult<Response> {
Ok(Response::new())
}
}

The #[sv::messages(interface<ContractInput>)] has generic brackets with only one type specified. Developers need to specify all associated types beyond ExecC, QueryC, and Error.

What if I need to implement a generic interface?

To understand the reasoning behind reverting support for generic traits, one must consider the constraints of procedural macros and the CosmWasm environment. For the purpose of this article, a simple example is prepared. It demonstrates an interface defined in two different ways: using generic types and using associated types:

pub mod interface_generic {
use cosmwasm_std::StdResult;
use sylvia::types::{CustomMsg, QueryCtx};

// #[sylvia::interface] - Sylvia does not support generic traits
pub trait InterfaceGeneric<InputT, ResultT>
where
InputT: CustomMsg,
ResultT: CustomMsg {

// #[sv::msg(query)]- Sylvia does not support generic traits
fn state_dependent_op(&self, ctx: QueryCtx, i: InputT)
-> StdResult<ResultT>;
}
}

pub mod interface_assoc {
use cosmwasm_std::{StdError, StdResult};
use sylvia::types::{CustomMsg, QueryCtx};

#[sylvia::interface]
pub trait InterfaceAssoc {
type InputT: CustomMsg;
type ResultT: CustomMsg;
type Error: From<StdError>;

#[sv::msg(query)]
fn state_dependent_op(&self, ctx: QueryCtx, i: Self::InputT)
-> StdResult<Self::ResultT>;
}
}

Below are two contracts prepared for the interfaces described above. The first assumes that the contract type is not generic, while the second one utilizes generics:

use cw_storage_plus::Item;

pub struct Contract {
state: Item<u32>,
}

pub struct ContractGeneric<T> {
state: Item<u32>,
_phantom: std::marker::PhantomData<T>,
}

The first approach is to implement InterfaceGeneric<InputT, ResultT> for the simple Contract type:

use sylvia::types::{CustomMsg, QueryCtx, StdResult};
use std::ops::Add;

impl<InputT> interface_generic::InterfaceGeneric<InputT, InputT> for Contract
where
InputT: Add<Output = InputT> + CustomMsg + Clone,
{
fn state_dependent_op(&self, ctx: QueryCtx, i: InputT) -> StdResult<InputT> {
let mut result = i.clone();
let upper_bound = self.state.load(ctx.deps.storage)?;
for _ in 0..upper_bound {
result = result + i.clone();
}
Ok(result)
}
}

#[sylvia::contract]
#[sv::messages(interface_generic<bool> as InterfaceGenericBool)]
#[sv::messages(interface_generic<u8> as InterfaceGenericU8)]
#[sv::messages(interface_generic<u16> as InterfaceGenericU16)]
// ... etc. for all the types that should be implemented
// for this contract.
//
// In order to generate the dispatch method for each message type,
// Sylvia needs information about all interfaces implemented for
// a given contract. If an interface has generic or associated
// types, they have to be listed.
//
impl Contract {
// [...]
}

Let’s analyze the above contract. The state_dependent_op adds the input to itself self.state times and returns the result. It is reasonable to use generic types for this interface in such a case.

There are two problems with that approach:

  1. It’s hard to make the generic part of the interface truly generic in the contract implementation. Even though the produced messages are generic, once the code is compiled there are only concrete types in the contract’s binary code due to contract monomorphisation in the entry points. Sylvia cannot arbitrarily choose which types of generic messages should be deserialized.
  2. Sylvia prevents collisions between method names for every interface implemented within a given contract. This decision aims to maintain the contract’s messages API flat. For example, an interface’s query message variants are added to the one set of all query variants in a given contract implementation. Specifically, developers cannot implement the same interface twice, even with different generics.

Let’s try to achieve a similar goal using ContractGeneric and InterfaceAssoc instead:

use cw_storage_plus::Item;
use sylvia::types::{CustomMsg, QueryCtx, StdResult};
use std::ops::Add;

impl<InputT> interface_assoc::InterfaceAssoc for ContractGeneric<InputT>
where
InputT: Add<Output = InputT> + CustomMsg + Clone,
{
type InputT = InputT;
type ResultT = InputT;
type Error = StdError;

fn state_dependent_op(&self, ctx: QueryCtx, i: Self::InputT)
-> StdResult<Self::ResultT> {
let mut result = i.clone();
let upper_bound = self.state.load(ctx.deps.storage)?;
for _ in 0..upper_bound {
result = result + i.clone();
}
Ok(result)
}
}

#[cosmwasm_schema::cw_serde]
struct Operand(u32);
impl cosmwasm_std::CustomMsg for Operand {}
impl Add for Operand {
type Output = Self;

fn add(self, other: Self) -> Self {
Self(self.0 + other.0)
}
}

#[sylvia::contract]
#[sv::messages(interface_assoc<InputT, InputT>)]
#[cfg_attr(not(feature = "library"), sylvia::entry_points(generics<Operand>))]
impl<InputT> ContractGeneric<InputT>
where
InputT: Add<Output = InputT> + CustomMsg + Clone,
{
pub fn new() -> Self {
Self {
state: Item::<u32>::new("state"),
_phantom: std::marker::PhantomData
}
}

#[sv::msg(instantiate)]
pub fn instantiate(&self, ctx: InstantiateCtx) -> StdResult<Response> {
self.state.save(ctx.deps.storage, &0)?;
Ok(Response::new())
}
}

The very important thing to notice is the entry_points macro. Several entry point types are defined by the CosmWasm standard. Each contract's binary can export, at most, one method per entry point. There's no difference in Sylvia’s contracts. The problem occurs when there's a generic contract. To provide the compiled contract's binary with one method per entry point, developers need to decide which monomorphisation of the contract should be used for this purpose. In the code above, a new Operand struct is defined to be used for this contract.

Conclusion

Recently, Sylvia v1.0.0 was released. A lot of effort was put into forging all the ideas behind that tool into a stable library. During the dynamic development phase, a lot of ideas were tested. In the process, generic traits turned out to be troublesome in Sylvia’s contracts for the CosmWasm environment. If the two problems mentioned above are addressed, the generic interfaces could potentially be implemented in the future. In any case, most situations involving interfaces can (and should) be addressed using associated types.

--

--