I’m concerned about how data types are handled in webserver applications. In particular, I’m concerned about how a message would be validated at the webpage and how we would be sure data types were correct at the server side after being originated at the web site. My REST implementation is in Python. My web page uses TypeScript and React. Can you tell me how the validation at the web page can be sufficient that it will be error-free as possible at the server and I don’t send garbage that can’t even be deserialized into the types declared at the server?
The answer starts with a principle: the spec is the single source of truth for your validation rules. The MCP Server exposes the exact constraints — regex patterns, length bounds, digit limits — that ISO 20022 defines for every primitive type. If you translate those constraints faithfully into both your Zod schemas (TypeScript) and your Pydantic models (Python), the two layers will reject exactly the same values. Nothing valid gets blocked. Nothing invalid slips through.
Let me pull the actual constraints for the types you’ll encounter most in payments initiation.
[Calling universal_lookup on IBAN2007Identifier, BICFIDec2014Identifier, Max35Text, ActiveOrHistoricCurrencyAndAmount…]
Here’s what the spec actually says, field by field:
| ISO 20022 type | Constraint | Source |
|---|---|---|
Max35Text |
String, minLength 1, maxLength 35 | ISO spec |
IBAN2007Identifier |
Pattern: [A-Z]{2,2}[0-9]{2,2}[a-zA-Z0-9]{1,30} + valid IBAN checksum |
ISO 13616 |
BICFIDec2014Identifier |
Pattern: [A-Z0-9]{4,4}[A-Z]{2,2}[A-Z0-9]{2,2}([A-Z0-9]{3,3}){0,1} |
ISO 9362:2014 |
ActiveOrHistoricCurrencyAndAmount |
Decimal ≥ 0, totalDigits 18, fractionDigits 5, currency = ISO 4217 3-letter code | ISO 4217 |
ChargeBearerType1Code |
Enum: DEBT, CRED, SHAR, SLEV |
Closed code set |
These are not conventions or best practices — they are the spec values. Any payload that passes these constraints will deserialize correctly at the server. Anything that fails them is structurally invalid before it ever reaches business logic.
The Strategy
Define the constraints once per type, in both layers, derived from the same spec source. The TypeScript layer validates user input before submission. The Python layer validates again on arrival — not because you distrust the frontend, but because network payloads can come from anywhere.
User input
│
▼
[Zod schema] ── rejects bad data ── form error shown
│
▼
HTTP POST (clean JSON)
│
▼
[Pydantic model] ── rejects bad data ── 422 Unprocessable Entity
│
▼
Business logic (guaranteed valid types)
Both validation layers must agree. If they diverge — even slightly — you get either false positives (the form accepts something the server rejects, causing confusing errors) or false negatives (the form rejects something the server would accept, blocking legitimate submissions).
TypeScript / Zod — Frontend Validation
Install once:
npm install zod
Define your primitive types directly from the spec constraints:
import { z } from "zod";
// Max35Text: minLength 1, maxLength 35
export const max35Text = z
.string()
.min(1, "Required")
.max(35, "Maximum 35 characters");
// IBAN2007Identifier: pattern from ISO 13616
// Note: structural pattern only — full checksum validation requires a library
export const iban2007Identifier = z
.string()
.regex(
/^[A-Z]{2}[0-9]{2}[a-zA-Z0-9]{1,30}$/,
"Must be a valid IBAN (e.g. GB29NWBK60161331926819)"
);
// BICFIDec2014Identifier: pattern from ISO 9362:2014
export const bicFiDec2014Identifier = z
.string()
.regex(
/^[A-Z0-9]{4}[A-Z]{2}[A-Z0-9]{2}([A-Z0-9]{3})?$/,
"Must be a valid BIC (e.g. NWBKGB2L)"
);
// ActiveOrHistoricCurrencyAndAmount: decimal >= 0, up to 5 fraction digits, ISO 4217 currency
export const currencyAndAmount = z.object({
amount: z
.number({ invalid_type_error: "Must be a number" })
.nonnegative("Amount must be zero or greater")
.multipleOf(0.00001, "Maximum 5 decimal places"),
currency: z
.string()
.length(3, "Currency must be a 3-letter ISO 4217 code")
.regex(/^[A-Z]{3}$/, "Currency code must be uppercase letters"),
});
// ChargeBearerType1Code: closed code set — use a dropdown, not free text
export const chargeBearerType1Code = z.enum(["DEBT", "CRED", "SHAR", "SLEV"], {
errorMap: () => ({ message: "Select a valid charge bearer" }),
});
Now compose these into your payment initiation form schema:
// Schema for the credit transfer initiation form — pain.001 subset
export const creditTransferFormSchema = z.object({
// GroupHeader fields
messageIdentification: max35Text,
numberOfTransactions: z
.number({ invalid_type_error: "Must be a number" })
.int()
.positive(),
// Payment information
debtorName: max35Text,
debtorAccountIban: iban2007Identifier,
debtorBic: bicFiDec2014Identifier,
// Credit transfer transaction
creditorName: max35Text,
creditorAccountIban: iban2007Identifier,
instructedAmount: currencyAndAmount,
chargeBearer: chargeBearerType1Code,
// Remittance info (free text, bounded)
remittanceInformation: z.string().max(140).optional(),
});
export type CreditTransferForm = z.infer<typeof creditTransferFormSchema>;
In a React form (using react-hook-form with the Zod resolver):
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
export function PaymentForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<CreditTransferForm>({
resolver: zodResolver(creditTransferFormSchema),
});
const onSubmit = async (data: CreditTransferForm) => {
// data is fully typed and validated — safe to POST
await fetch("/api/payments/initiate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("debtorAccountIban")} placeholder="IBAN" />
{errors.debtorAccountIban && (
<span>{errors.debtorAccountIban.message}</span>
)}
{/* ... remaining fields ... */}
</form>
);
}
Python / Pydantic — Server-Side Validation
The same spec constraints, expressed in Pydantic v2:
import re
from decimal import Decimal
from typing import Annotated, Literal
from pydantic import BaseModel, Field, field_validator
# ── Primitive type aliases ──────────────────────────────────────────────────
# Max35Text: minLength 1, maxLength 35
Max35Text = Annotated[str, Field(min_length=1, max_length=35)]
# IBAN2007Identifier: structural pattern (ISO 13616)
_IBAN_PATTERN = re.compile(r'^[A-Z]{2}[0-9]{2}[a-zA-Z0-9]{1,30}$')
# BICFIDec2014Identifier: ISO 9362:2014
_BIC_PATTERN = re.compile(r'^[A-Z0-9]{4}[A-Z]{2}[A-Z0-9]{2}([A-Z0-9]{3})?$')
# ActiveOrHistoricCurrencyAndAmount: totalDigits 18, fractionDigits 5, minInclusive 0
CurrencyCode = Annotated[str, Field(pattern=r'^[A-Z]{3}$', min_length=3, max_length=3)]
# ChargeBearerType1Code: closed code set
ChargeBearerType1Code = Literal["DEBT", "CRED", "SHAR", "SLEV"]
# ── Structured types ────────────────────────────────────────────────────────
class ActiveOrHistoricCurrencyAndAmount(BaseModel):
amount: Decimal = Field(ge=0, decimal_places=5, max_digits=18)
currency: CurrencyCode
# ── Message building blocks ─────────────────────────────────────────────────
class CreditTransferInitiationRequest(BaseModel):
message_identification: Max35Text
number_of_transactions: int = Field(gt=0)
debtor_name: Max35Text
debtor_account_iban: str
debtor_bic: str
creditor_name: Max35Text
creditor_account_iban: str
instructed_amount: ActiveOrHistoricCurrencyAndAmount
charge_bearer: ChargeBearerType1Code
remittance_information: Annotated[str, Field(max_length=140)] | None = None
@field_validator("debtor_account_iban", "creditor_account_iban")
@classmethod
def validate_iban(cls, v: str) -> str:
if not _IBAN_PATTERN.match(v):
raise ValueError(
"Must match IBAN pattern: 2 uppercase letters, 2 digits, "
"1–30 alphanumeric characters"
)
return v
@field_validator("debtor_bic")
@classmethod
def validate_bic(cls, v: str) -> str:
if not _BIC_PATTERN.match(v):
raise ValueError(
"Must match BIC pattern per ISO 9362:2014"
)
return v
Your FastAPI endpoint receives and auto-validates in one step:
from fastapi import FastAPI
app = FastAPI()
@app.post("/api/payments/initiate")
async def initiate_payment(request: CreditTransferInitiationRequest):
# If execution reaches here, every field is valid per the ISO 20022 spec.
# Pydantic has already rejected the request with a 422 if anything is wrong.
return await process_payment(request)
Keeping the Two Layers in Sync
The risk over time is that the Zod schema and the Pydantic model diverge as the codebase evolves. Two practices help:
1. Comment each constraint with its spec source. When a constraint changes in the spec (rare, but it happens), you know exactly what to update in both places:
// IBAN2007Identifier — ISO 13616, pattern from ISO 20022 spec ID _YYxm0dp-Ed-ak6NoX_4Aeg_1226525818
export const iban2007Identifier = z.string().regex(/^[A-Z]{2}[0-9]{2}[a-zA-Z0-9]{1,30}$/, ...);
2. Test against the same payloads. A small shared fixture file of valid and invalid examples (in JSON) can be run against both layers. The Zod schema and the Pydantic model should accept and reject identically. Any divergence is a bug.
Four
universal_lookupcalls returned the exact patterns, length bounds, and digit constraints ISO 20022 defines for every primitive type in the message. Translating those into Zod and Pydantic is mechanical — not interpretive. Both layers now reject exactly the same payloads, and the spec itself is the paper trail for every validation rule in the codebase.
If you want the full constraint set for PaymentInformation or CreditTransferTransaction — the blocks that carry the actual beneficiary and amount data — the MCP Server can walk those structures the same way.