I’m a Rust programmer. I want to create a library of the messages in the payments initiation area. Can you illustrate how a data container should look for a standard message, for a message based on a choice, and for a codeset? Am I correct that codesets and choices should both be implemented as enums in Rust?
Yes — and it’s a perceptive question, because they’re both enums but for completely different reasons. Let me pull the actual types from the spec.
[Calling universal_search on the ISO 20022 MCP Server: GroupHeader credit transfer initiation…]
[Calling universal_lookup on GroupHeader114, AccountIdentification4Choice, ChargeBearerType1Code…]
The MCP Server confirms three distinct structural kinds in the payments initiation area — and they map cleanly to three different Rust patterns:
GroupHeader114is aMessageComponent: a named set of fields, some required, some optional. This is astruct.AccountIdentification4Choiceis aChoiceComponent: exactly one of two alternatives — either an IBAN or a proprietary identifier. This is a data-carryingenum.ChargeBearerType1Codeis aCodeSet: a closed list of named values with no associated data. This is a unitenum.
So yes to your question — both ChoiceComponent and CodeSet become Rust enums, but structurally they’re very different, and they deserve different derives. The spec’s own type taxonomy tells you which pattern to apply; you never have to guess.
Before I show the code: what serialization approach are you targeting? serde with quick-xml, serde-xml-rs, or something else?
Rust developer: serde and quick-xml.
Claude: Good choice — that’s the most ergonomic path for ISO 20022 XML in Rust. Here are the three patterns, all grounded in live spec data.
Pattern 1 — Standard Message Component → struct
The spec defines GroupHeader114 as the GrpHdr building block of pain.001.001.12 (CustomerCreditTransferInitiationV12). The MCP Server returns its eight fields directly:
| Spec name | XML tag | Occurs | Type |
|---|---|---|---|
| MessageIdentification | MsgId |
1..1 | Max35Text |
| CreationDateTime | CreDtTm |
1..1 | ISODateTime |
| Authorisation | Authstn |
0..2 | Authorisation2Choice |
| NumberOfTransactions | NbOfTxs |
1..1 | Max15NumericText |
| ControlSum | CtrlSum |
0..1 | DecimalNumber |
| InitiatingParty | InitgPty |
1..1 | PartyIdentification272 |
| ForwardingAgent | FwdgAgt |
0..1 | BranchAndFinancialInstitutionIdentification8 |
| InitiationSource | InitnSrc |
0..1 | PaymentInitiationSource1 |
maxOccurs = 1 with minOccurs = 1 → required field. minOccurs = 0 → Option<T>. maxOccurs = 2 with minOccurs = 0 → Vec<T> (bounded, but Rust doesn’t enforce the upper bound in the type — validate at runtime or with a newtype).
use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
/// GroupHeader114 — GrpHdr block of pain.001.001.12
/// ISO ID: _yjuzITEyEe6g-ffJsqGiSA
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename = "GrpHdr")]
pub struct GroupHeader114 {
/// Point-to-point reference assigned by the instructing party.
#[serde(rename = "MsgId")]
pub message_identification: Max35Text,
/// Date and time at which the message was created.
#[serde(rename = "CreDtTm")]
pub creation_date_time: DateTime<Utc>,
/// User identification or key for authorisation checks. 0..2 occurrences.
#[serde(rename = "Authstn", default, skip_serializing_if = "Vec::is_empty")]
pub authorisation: Vec<Authorisation2Choice>,
/// Number of individual transactions in the message.
#[serde(rename = "NbOfTxs")]
pub number_of_transactions: Max15NumericText,
/// Total of all individual amounts, irrespective of currencies.
#[serde(rename = "CtrlSum", skip_serializing_if = "Option::is_none")]
pub control_sum: Option<Decimal>,
/// Party that initiates the payment.
#[serde(rename = "InitgPty")]
pub initiating_party: PartyIdentification272,
/// Financial institution that forwards the instruction.
#[serde(rename = "FwdgAgt", skip_serializing_if = "Option::is_none")]
pub forwarding_agent: Option<BranchAndFinancialInstitutionIdentification8>,
/// Source application or software used to initiate the payment.
#[serde(rename = "InitnSrc", skip_serializing_if = "Option::is_none")]
pub initiation_source: Option<PaymentInitiationSource1>,
}
/// Constrained string types mirror ISO 20022 primitive types.
/// A newtype wrapper lets you enforce length constraints in one place.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Max35Text(pub String);
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Max15NumericText(pub String);
Every MessageComponent in the spec maps to a Rust struct. The field names follow your codebase’s conventions; the #[serde(rename)] attributes carry the ISO XML tag.
Pattern 2 — Choice Component → data-carrying enum
The spec defines AccountIdentification4Choice as a ChoiceComponent with exactly two variants — you provide one or the other, never both:
| Variant | XML tag | Type | Definition |
|---|---|---|---|
| IBAN | IBAN |
Iban2007Identifier | International Bank Account Number |
| Other | Othr |
GenericAccountIdentification1 | Any other account identification scheme |
/// AccountIdentification4Choice — account ID as IBAN or proprietary scheme.
/// ISO ID: _Pdwzodp-Ed-ak6NoX_4Aeg_-1833658219
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename = "Id")]
pub enum AccountIdentification4Choice {
/// International Bank Account Number (IBAN).
#[serde(rename = "IBAN")]
Iban(Iban2007Identifier),
/// Proprietary account identification (sort code + account number, etc.).
#[serde(rename = "Othr")]
Other(GenericAccountIdentification1),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Iban2007Identifier(pub String);
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GenericAccountIdentification1 {
#[serde(rename = "Id")]
pub id: Max34Text,
#[serde(rename = "SchmeNm", skip_serializing_if = "Option::is_none")]
pub scheme_name: Option<AccountSchemeName1Choice>,
#[serde(rename = "Issr", skip_serializing_if = "Option::is_none")]
pub issuer: Option<Max35Text>,
}
The key difference from Pattern 1: each variant carries data — either an IBAN string or a GenericAccountIdentification1 struct. This is Rust’s tagged-union enum doing exactly what ISO 20022 ChoiceComponent means semantically.
quick-xmlnote: XML choice serialization requires care. By default,serderepresents enum variants as a wrapper element. For ISO 20022 you typically want#[serde(untagged)]or a custom deserializer so that<IBAN>GB29NWBK...and<Othr>...</Othr>round-trip cleanly without a wrapping element. Test this against your bank’s schema validator before going to production.
Pattern 3 — Code Set → unit enum
The spec defines ChargeBearerType1Code as a CodeSet with four codes specifying who pays transaction charges. And PaymentMethod3Code with three codes for transfer method. Both are pure enumerations — no associated data per variant.
/// ChargeBearerType1Code — who pays charges due for processing.
/// ISO ID: _a8PpINp-Ed-ak6NoX_4Aeg_940131843
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ChargeBearerType1Code {
/// All charges are borne by the debtor. XML: DEBT
#[serde(rename = "DEBT")]
BorneByDebtor,
/// All charges are borne by the creditor. XML: CRED
#[serde(rename = "CRED")]
BorneByCreditor,
/// Charges are shared between debtor and creditor. XML: SHAR
#[serde(rename = "SHAR")]
Shared,
/// Charges follow the agreed service level. XML: SLEV
#[serde(rename = "SLEV")]
FollowingServiceLevel,
}
/// PaymentMethod3Code — transfer method for an amount of money.
/// ISO ID: _Z0aeYtp-Ed-ak6NoX_4Aeg_1813568342
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum PaymentMethod3Code {
/// Payment by cheque. XML: CHK
#[serde(rename = "CHK")]
Cheque,
/// Payment by credit transfer. XML: TRF
#[serde(rename = "TRF")]
CreditTransfer,
/// Payment by transfer advice. XML: TRA
#[serde(rename = "TRA")]
TransferAdvice,
}
Note Copy — code set enums are cheap value types, exactly like Rust’s own std enums. They’re also good candidates for Hash since they often appear in lookup tables.
To Directly Answer Your Question
Yes — both ChoiceComponent and CodeSet should be Rust enums, but they’re structurally different:
| ISO 20022 type | Rust pattern | Variants carry data? | Typical derives |
|---|---|---|---|
MessageComponent |
struct |
— | Debug, Clone, Serialize, Deserialize |
ChoiceComponent |
enum with data |
Yes — each variant wraps a type | Debug, Clone, Serialize, Deserialize |
CodeSet |
enum unit variants |
No | Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize |
The structural alignment is clean: ISO 20022’s three fundamental building-block kinds map directly onto Rust’s three fundamental composite type forms.
Putting It Together
// A fragment of how GroupHeader114 uses these patterns:
let header = GroupHeader114 {
message_identification: Max35Text("MSGID-20260326-001".to_string()),
creation_date_time: Utc::now(),
authorisation: vec![],
number_of_transactions: Max15NumericText("3".to_string()),
control_sum: Some(Decimal::new(150000, 2)), // 1500.00
initiating_party: PartyIdentification272 { /* ... */ },
forwarding_agent: None,
initiation_source: None,
};
// AccountIdentification4Choice — IBAN variant
let account_id = AccountIdentification4Choice::Iban(
Iban2007Identifier("GB29NWBK60161331926819".to_string())
);
// ChargeBearerType1Code — most SEPA credit transfers use SLEV
let charge_bearer = ChargeBearerType1Code::FollowingServiceLevel;
The MCP Server returned the exact field list for
GroupHeader114straight from the spec — names, XML tags, and occurrence constraints — in one lookup. TranslatingminOccurs/maxOccurstoOption<T>andVec<T>is mechanical from there. The choice and code set patterns fell out of the spec’s own type taxonomy:ChoiceComponent→ data enum,CodeSet→ unit enum. No spec-reading, no guessing, no wrong field names.
Want the full PaymentInformation block or the CreditTransferTransaction structure next? The MCP Server can walk the entire pain.001 tree the same way.