Data representation: from Haskell types to Plutus Core

A Plinth project holds on-chain and off-chain code in one package, and both halves work with the same Haskell datatypes. For that to work, a Haskell value must have a representation at the Plutus Core level — and for datums, redeemers, and script parameters, a representation both sides agree on exactly. This page explains how Haskell types map onto Plutus Core, the two representations your own datatypes can take, and the classes and helpers that move values across the boundary.

How Haskell types map to Plutus Core

A handful of Haskell types are the UPLC builtin types, and map straight onto them:

Haskell Plutus Core builtin
Integer integer
BuiltinByteString bytestring
BuiltinString string
BuiltinList, BuiltinPair list, pair
BuiltinBool, BuiltinUnit bool, unit
BuiltinData data

Note what is not in that table. The everyday prelude types — Bool, Maybe, lists, tuples — are ordinary Haskell datatypes, not builtins; they compile with the sum-of-products representation described next. (Bool is literally the standard Bool; the builtin is the separate BuiltinBool.)

Your own datatypes — records and sum types — likewise do not have a single fixed mapping. They have a choice of representation, which is the subject of the rest of this page. One builtin above is special: Data, because it is the only thing the ledger accepts at the boundary. Every datum, redeemer, and parameter crosses as Data.

Two representations for your datatypes

An algebraic datatype can be represented in Plutus Core in two ways.

Sum-of-products (the default). An ordinary data declaration compiles to UPLC sum-of-products terms — constr to build a variant and case to match one (before UPLC 1.1.0 this was a Scott encoding of lambdas). Construction and pattern matching are direct and cheap. This is the representation for datatypes used inside the contract: intermediate values, helper types, anything you compute over.

Builtin Data. The universal tree of tagged constructors, maps, lists, integers (I), and byte strings (B). This is the only representation the ledger accepts for datums, redeemers, and parameters, and it is what the blueprint schema describes.

The two are different, so crossing the boundary means converting: a sum-of-products value must become Data to leave the script, and incoming Data must become a sum-of-products value before typed code can use it. Those conversions are the job of the ToData / FromData classes.

ToData, FromData, and UnsafeFromData

BuiltinData is the on-chain handle to a Data value. Three classes convert between it and your types:

class ToData a where
  toBuiltinData :: a -> BuiltinData

class FromData a where
  fromBuiltinData :: BuiltinData -> Maybe a

class UnsafeFromData a where
  unsafeFromBuiltinData :: BuiltinData -> a

toBuiltinData always succeeds. Decoding can fail (the bytes may not match the type), so there are two ways to do it: fromBuiltinData returns Maybe, while unsafeFromBuiltinData calls error on a mismatch and is typically much faster. On-chain code usually takes the unsafe route — the validator wrapper decodes its argument with unsafeFromBuiltinData, since a malformed argument should just fail the script. (When you write an instance by hand, decode substructures with unsafeFromBuiltinData too, or you lose the speed.)

You rarely write these instances yourself.

Deriving the instances

Template Haskell helpers derive the conversions:

data AuctionRedeemer = NewBid Bid | Payout
PlutusTx.makeIsDataIndexed ''AuctionRedeemer [('NewBid, 0), ('Payout, 1)]
  • makeIsDataIndexed ''T [(...)] derives ToData, FromData, and UnsafeFromData using an explicit mapping of constructors to integer indices. Those indices are the on-chain encoding: they end up in the Data tree and in the blueprint schema. Pinning them by hand keeps the encoding stable when you add, remove, or reorder constructors — which is why anything persisted on-chain or shared with off-chain tooling should use this.
  • unstableMakeIsData ''T derives ToData and FromData with indices assigned automatically from constructor order. Convenient, but the encoding shifts if the constructors change — only safe for throwaway types you control end to end.
  • makeIsDataSchemaIndexed (from PlutusTx.Blueprint) is like makeIsDataIndexed and also derives the CIP-57 schema; see Generate a blueprint.
  • makeLift is related but separate: it derives a Lift instance, which splices a Haskell value into a script as a constant. This is how parameters are applied (liftCode), and it does not go through Data.

Choosing a representation: ordinary data vs asData

By default a datatype is sum-of-products, and you add makeIsDataIndexed so it can cross the boundary. Each crossing then fully encodes or decodes the whole value at once.

The alternative is to back the datatype with Data directly, using asData:

import PlutusTx.AsData qualified as AsData

$(AsData.asData
    [d|
      data Vote = Yes | No | Abstain Integer
        deriving newtype (Eq)
    |])

This replaces the datatype with a newtype around BuiltinData and generates pattern synonyms (Yes, No, Abstain) that behave like the original constructors. Because the value is already Data, crossing the boundary is free — no conversion happens. The cost moves elsewhere: the pattern synonyms call toBuiltinData / unsafeFromBuiltinData on the fields on every construction and every pattern match.

That trade-off decides which to use:

  • asData pays off for values you mostly pass around and rarely inspect, and it compounds well when the field types are also defined with asData (no repeated re-encoding of nested structure).
  • It is expensive for types you pattern-match on heavily, or whose fields are ordinary sum-of-products values — each access re-encodes them. For those, the plain representation plus makeIsDataIndexed is cheaper.

A future Plinth release will let you select the Data-backed representation by deriving it on an ordinary datatype, removing the $(asData [d| ... |]) Template Haskell boilerplate shown above.

Sharing across the boundary

This is what lets the two halves of a project share data safely. The off-chain code builds a datum, redeemer, or parameter from a Haskell value with toBuiltinData; the validator decodes the very same Data back with unsafeFromBuiltinData. Both use the same datatype and the same derived instances, so the encoding each side assumes is identical by construction — they cannot drift out of agreement. The test guide uses exactly this to feed a redeemer to a compiled script, and blueprint generation publishes the encoding so non-Haskell tooling can match it too.

Further reading