Decoding
On this page
Providers often send values in odd forms: a date as Unix seconds, a
number as text, money as a whole number of pence. Decoding turns these
into proper typed values, so your code only ever sees an Instant, a
Decimal or Money. The package declares how each value is written on
the wire, and the runtime decodes it.
Decoding is one of two kinds of conversion. To tell which one you need, ask: is it needed to make the value mean what the provider's documentation says it is?
- Yes: decoding. Dates as Unix seconds, numbers as text, lists as
"a,b,c", an envelope around the real object, an error inside a200, a columnar table that is really a list of rows, or Stripe'samount: 1250, currency: "gbp", which is really money. The package declares it and the runtime does it. - No: translation. Turning a correct value into the shape one app prefers. Your app's action does it, with an explicit call.
Decoding is declared on fields and on operations:
// packages/acme_couriers/v1/types/parcel.jadpopublic type Parcel = Object { collect_at: Instant { wire: Wire.unix_seconds } weight: Decimal { scale: 2 wire: Wire.text } price: Money { wire: Wire.minor_units currency: Currency.gbp } slot: SlotWindow { decode: slots.parse_window encode: slots.format_window }}// packages/acme_couriers/v1/resources/tracking.jadpopublic resource tracking { get: { method: HttpMethod.get path: "/v1/parcels/{id}/tracking" input: TrackingLookup wire_output: TrackingTable output: List<TrackingEvent> decode: tables.to_events output_at: "data" failed_when: { path: "ok" equals: false } changes: false source: PackageSource.written docs: "https://docs.acme-couriers.example/api#tracking" }}
| Written | On | Means |
|---|---|---|
wire: Wire.x |
A field | One of the built-in encodings: Unix seconds and milliseconds, ISO dates, numbers as text, joined lists, base64, JSON inside a string, money in minor units. A fixed list extended by Jadpo releases |
decode: and encode: |
A field | A pure function pair from the package's own libraries/, for anything else; checked to round-trip at publish |
wire_output: and decode: |
An operation | The response as sent, and a pure function turning it into output:; run over every recorded response at publish |
output_at: |
An operation | The path of the real object inside an envelope |
failed_when: |
An operation | A 200 answer that is really a failure, by a path and the value that marks it (ok is false); the failure's code is then read from error_code: |
- A field without
wire:is written as Jadpo's contract writes it. The contract is the one JSON form each standard type has at every boundary (Standard library). A standard type in a provider's request, response or webhook is encoded that way, by the same codec a route's request body goes through. So an amount is{"amount": 1250, "currency": "GBP"}, and a quantity keeps its unit. Writewire:only where the provider uses another encoding. - Byte formats stay in the runtime: JSON, form-encoded, multipart, and XML later. Package functions reshape values, never bytes.
- Decoding is lazy where it can be. A field decoder runs only when code reads the field. A whole-response decoder reads what it reads, so a provider adding fields it ignores breaks nothing.
- A value that won't decode is a fault naming the operation and field, as a field that doesn't match its type is. Contract runs against the sandbox catch it before production does.
- App tests script the clean type, never the wire shape. The package's own tests and recordings cover its decoders. Webhook payloads decode the same way.
- The importer can't see most of this, so the agent adds it with the
other corrections, and
jadpo package recordconfirms it against a real response. - The compiler can't police the line between decoding and translation. The surface shows both the wire type and the output type, so the published documentation and the review make it visible.
Spellings: wire_name:
Write wire_name: on a provider package's field or enum variant only
where the provider's spelling differs from the Jadpo name. The runtime
then sends and reads that spelling. A field or variant gives its spelling
in its own block. Here failed_when: also reads the failure's code from
the provider's error_code::
/// A booked collection.public type Booking = Object { id: Text collect_at: Instant { wire_name: "collectAt" } part_of_day: SlotWindow { wire_name: "partOfDay" }}
/// When in the day a collection comes.public type SlotWindow = Enum { morning after_noon: { wire_name: "after-noon" }}
public resource bookings { create: { method: HttpMethod.post path: "/v1/bookings" input: CreateBooking output: Booking output_at: "data" failed_when: { path: "ok" equals: false } changes: true idempotent: true failures: [SlotTaken] source: PackageSource.written }}
So {"ok": true, "data": {"id": "b-1", "collectAt": "2026-10-20T09:00:00Z", "partOfDay": "after-noon"}} is read as a Booking with collect_at
and part_of_day: SlotWindow.after_noon, and {"ok": false, "error": {"code": "slot_taken"}} as the operation's SlotTaken.
The built-in encodings: wire:
A field names one of these ten built-in encodings with wire::
wire: |
On a field of | The provider sends |
|---|---|---|
Wire.unix_seconds |
Instant |
A whole number of seconds since 1970: 1792400400 |
Wire.unix_milliseconds |
Instant |
A whole number of milliseconds since 1970 |
Wire.iso_8601 |
Instant |
ISO 8601 text with any offset from UTC, "2026-10-19T10:00:00+01:00"; written in UTC |
Wire.day_month_year |
CalendarDate |
"19/10/2026" |
Wire.text |
Int, Decimal |
The number as text: "3", "2.50" |
Wire.number |
Int, Decimal |
A JSON number, every place written kept: 2.50 |
Wire.comma_separated |
A list of text, numbers or an enum's plain variants | One text, joined by commas: "fragile,heavy" |
Wire.base64 |
Text |
The Base64 of its UTF-8 |
Wire.json_text |
Anything | Its JSON, inside a string |
Wire.minor_units |
Money whose type fixes its currency: |
A whole number of the currency's minor units: 1250 |
A field's own functions: decode: and encode:
For anything the built-in encodings don't cover, a field names a pair of
functions. decode: takes the value as the provider sends it and gives
the field's type, failing when it can't. encode: turns the field's type
back, without failing. Both live in the package's own libraries/, so
they may be internal:
/// A parcel and where it is.public type Parcel = Object { id: ParcelRef status: Status collect_at: CollectionTime { wire: Wire.unix_seconds wire_name: "collectAt" } weight: Weight { wire: Wire.number } price: ParcelPrice { wire: Wire.minor_units } slot: SlotWindow { decode: courier_windows.parse_window encode: courier_windows.format_window }}
/// The courier's collection windows, which it writes as "AM" and "PM".library courier_windows { /// The window the courier's code names. function parse_window(code: WindowCode) fails WindowUnknown -> SlotWindow { if (code == "AM") { return SlotWindow.morning } if (code == "PM") { return SlotWindow.afternoon } reject WindowUnknown } /// The courier's code for a window. function format_window(pickup_window: SlotWindow) -> WindowCode { if (pickup_window == SlotWindow.morning) { return "AM" } return "PM" }}
A whole answer: wire_output:
An operation's wire_output: is the whole answer as sent, read from
inside output_at:. Its decode: turns it into output::
public resource tracking { get: { method: HttpMethod.get path: "/v1/parcels/{id}/tracking" input: ParcelLookup wire_output: TrackingTable output: List<TrackingEvent> decode: tracking_sheets.to_events output_at: "data" changes: false source: PackageSource.written }}
/// The courier's tracking history, as a list of where a parcel was seen.library tracking_sheets { /// Each sighting as an event. function to_events(history_sheet: TrackingTable) -> List<TrackingEvent> { return list.map( history_sheet.sightings, sighting -> TrackingEvent { hub: sighting.hub } ) }}
An action that reads one field of an answer decodes that field alone. So
{"id": "p-1", "status": "held", "collectAt": "soon", "slot": "NOON"} is
read for its status, even though its time and its window would never
decode:
use app { couriers }use shared/providers/acme_couriers/// Where a parcel is.action parcel_status(parcel: AcmeCouriers.ParcelRef) -> AcmeCouriers.Status { var tracked_parcel: AcmeCouriers.Parcel = couriers.parcels.get( AcmeCouriers.ParcelLookup { id: parcel } ) return tracked_parcel.status}
Parts sent separately: wire_parts:
Some providers send a standard type's parts as separate members of the
object that holds it. Stripe sends "amount": 1050, "currency": "gbp"
beside a charge's other members. In Jadpo that is one field, whose
wire_parts: names the member each part is read from and written to. The
field has no member of its own on the provider's wire. Fields may share
one part's member, as a charge's amounts share its currency:
/// A charge to a card.public type Charge = Object { id: ChargeId amount: Amount { wire_parts: { amount: "amount" currency: "currency" } } amount_refunded: Amount { wire_parts: { amount: "amount_refunded" currency: "currency" } } merchant: Point? { wire_parts: { latitude: "lat" longitude: "lng" } }}
public resource charges { get: { method: HttpMethod.get path: "/v1/charges/{id}" input: ChargeLookup output: Charge changes: false source: PackageSource.written }}
use app { payments }use shared/providers/tidepay/// How much of a charge has been refunded.action refunded_so_far(charge: Tidepay.ChargeId) -> Tidepay.Amount { var paid_charge: Tidepay.Charge = payments.charges.get( Tidepay.ChargeLookup { id: charge } ) return paid_charge.amount_refunded}
So {"id": "ch_1", "amount": 1050, "amount_refunded": 250, "currency": "gbp", "lat": 51.5, "lng": -0.125} is read as a Charge of £10.50, with
£2.50 refunded, at a merchant at 51.5° N, 0.125° W. A Charge is sent
back as those six members, the currency once. Two amounts in different
currencies can't be sent, since they share one member, and the call
stops as one that doesn't encode.
A Point is read from its latitude and longitude, which GeoJSON
would send as a pair. Any other standard type made of named parts is read
from those parts: Money's amount, in minor units, and currency;
InstantRange's start and end. A field none of whose members is sent
is none where it may be, and one with only some is a fault naming the
missing member. Everywhere else, the field is written as the contract
writes its type.
How decoding runs
- Where they apply. On the provider's wire: an operation's input,
its answer and its webhooks. Everywhere else, a
json.write, a route's answer or an event's payload, the field is written as the contract writes its type, so the app's clients never see a provider's encoding. - Run at the call's site. A field's
decode:andencode:, and an operation'sdecode:, run on the executor where the call is made, as any function the action calls would; one that fails, or gives no value, is a fault naming the operation and the field, never the value. - Only the fields read. The answer to a call held in a variable that
code only reads fields of (
tracked_parcel.status) is decoded for those fields alone; returning it, passing it on, or anything else uses it whole. Fields further in are decoded with the field that holds them. An answer decoded whole by an operation'sdecode:is read whole.
Markdown for agents: /docs/decoding.md.Built from Jadpo main at a707efb.