At the very end of episode 2 of our new Introduction to Daml, @Levente_Barczy mentions that serious Daml projects will use custom record types extensively. I think Daml developers should consider adding records to their repertoire as soon as possible, because the structure of templates lends itself to an especially direct use of records.
Records and their templates
Every record is exactly as powerful as the fields in it, no more, no less. That means that if you have three fields in a template, perhaps in several places,
foo: Foo
bar: Bar
baz: Baz
you may instead write
data Quux = Quux with
foo: Foo
bar: Bar
baz: Baz
deriving (Eq, Show)
and replace those three fields where they appear with
quux: Quux
and, while the structure of the payload is different, there are no contracts that are possible in one form but not the other.
If you then inspect the Payment
module from the video, you’ll find three occurrences of the same set of five fields. Sounds like a clear use case for factoring with data
, right? However…
Every template has a record
Consider the first of those templates:
template Payable
with
amount: Decimal
currency: Text
from: Party
to: Party
reference: Text
This implies the existence of a record that looks like this:
data Payable = Payable with
amount: Decimal
currency: Text
from: Party
to: Party
reference: Text
deriving (Eq, Show)
This automatic definition is so fundamental that, as we describe it in the Daml-LF language specification, a template is just a record type with various definitions associated with it. But for convenience we typically think of the record type as the template’s associated record type.
Having a value of the associated record type does not mean “this is a contract on the ledger”
When I refer to a value of type ContractId Payable
, I’m referring to a contract that actually may exist on the ledger. Of course, any contract can be archived at any time by the signatories, but it isn’t unreasonable to treat a contract ID as reference to a valid contract, failing if the contract doesn’t exist.
However, if you consider a typical create
call,
create Payable with
from = newOwner
-- ...
this is not any kind of “create syntax”. It is really of the form
create (Payable with
from = newOwner
...)
in which things happen in two steps.
- We create an ordinary value of record type
Payable
. This is not a contract yet, it is just a grouping of data in the associated record type’s fields. - We call the ordinary function
create
, which effectively asks the ledger to create a contract whose payload is the record value.
To illustrate, we could even write this as the following:
do
let it = Payable with
from = newOwner
-- ...
create it
and in this context, it
is an ordinary variable of type Payable
. Until we actually call create
on it
, all we’re doing is rearranging data in memory.
Data-accumulative workflows
The Payment
workflow, Payable
⇒ PaymentClaim
⇒ Receipt
, follows a pattern I think is quite common to many workflows. The first step has the five fields listed above. The second step has all five fields, plus a new field transactionId
. The third step has all six fields, plus the new field received
. Instead of duplicating the fields, here is how you might express this:
template Payable
with
amount: Decimal
currency: Text
from: Party
to: Party
reference: Text
-- ...
template PaymentClaim
with
pymt: Payable
transactionId: Text
-- ...
template Receipt
with
claim: PaymentClaim
received: Time
What I have lost in explicitness of having each piece of data in the contract declared right there, I have gained in explicitness about how the data flows through the workflow, because, if you are comfortable with record types, it is immediately apparent that "Receipt
is all the data in PaymentClaim
plus a received
time", and similarly for PaymentClaim
and Payable
.
However, the data is structured differently, even if it is impossible for that data to represent anything different. So there are some adaptations to the code that must be made.
Creating the next step
The ClaimPaid
choice formerly looked like this:
controller from can
ClaimPaid: ContractId PaymentClaim
with
transactionId: Text
do
create PaymentClaim with ..
This doesn’t work because we aren’t passing any of the existing fields as standalone fields when creating PaymentClaim
; instead, we want to pass a single value, the record that represents the contract prior to PaymentClaim
in the workflow. To supply it, we can simply change the last line to
create PaymentClaim with pymt = this; ..
In this form, ..
is no longer doing the heavy lifting of passing lots of fields; it’s only responsible for passing the new data, transactionId
.
Referring to indirect data
The signatories in Receipt
ultimately derive from the Payable
that started this little workflow. They were written
signatory to, from
However, to
and from
are no longer in scope. Instead, the most trivial port to the new data structure is
signatory claim.pymt.to, claim.pymt.from
Here is another demonstration that explicitness is in the eye of the beholder. Our original form was more explicit in passing along to
and from
, redeclaring in each template; this new form is more explicit in that it directly states that the provenance of to
and from
is “they come from the claim, which got them from the payable”. What is better? I do not think a single rule is wise; instead, you should exercise judgement and choose the form in all cases that you think best and most clearly expresses the meaning of your contracts.
Nevertheless, you do not have to rewrite every variable reference to suit this style. Just as ..
packs variables into a record, it can also unpack them back into variables. We can write this signatory
as
signatory let Payable{..} = claim.pymt in [to, from]
Probably not worth it in this case. But consider the example of Token
and TokenOffer
.
template Token
with
-- 8 fields
-- ...
template TokenOffer
with
-- same 8 fields as in Token
newOwner: Party
price: Decimal
where
-- ...
controller newOwner, userAdmin can
AcceptToken: (ContractId Token, ContractId Payable, ContractId Payable)
do
-- many references to the 8 fields from Token
By my count, AcceptToken
contains 13 references to the various fields that were originally derived from Token
. So when I make the port as follows,
template TokenOffer
with
token: Token
newOwner: Party
price: Decimal
I might have to prefix all of those references with token.
, as well as expand the usages of ..
, which no longer have the variables they need in scope. Instead, I can simply pull every member of token
into scope, by adding to the top of AcceptToken
:
controller newOwner, token.userAdmin can
AcceptToken: (ContractId Token, ContractId Payable, ContractId Payable)
do
let Token{..} = token -- makes a variable of every token field
-- many references continue working as before
Updating nested fields
One disadvantage of the packed format is that updating inner fields can be a little more complex. For example, we could add a choice for scribbling a note on a receipt to the original Receipt
:
controller from can
Scribble: ContractId Receipt
with
note: Text
do create this { reference = reference <> note }
But this no longer works, because Receipt
doesn’t directly contain reference
. Unpacking can help us with referring to the reference
field but not updating the field. When we update a field like this, we have to repack by referring to the full field name (in SDK 1.13 or later):
controller claim.pymt.from can
Scribble: ContractId Receipt
with
note: Text
do create this with
claim.pymt.reference = claim.pymt.reference <> note
For the specific use case of the accumulative data workflow, I don’t believe that you should encounter this very often, because typically you want the workflow to get each piece of data right when it is introduced, and not rely on later updates, because the type-checker won’t help you if you’ve forgotten an update.
What if fields are subtracted instead of added?
So it turns out that Token
wants to pass on all eight fields to TokenOffer
. What if we only wanted to pass seven fields, as one field was unused?
We actually have some examples of this workflow order in the video. An IssuerRequest
leads into an Issuer
as follows:
template Issuer
with
userAdmin: Party
issuer: Party
-- ...
template IssuerRequest
with
userAdmin: Party
issuer: Party
reason: Text
where
-- ...
controller userAdmin can
GrantIssuerRights: ContractId Issuer
do create Issuer with ..
Even though we’re removing the reason
, we can still use record factoring; in fact, I think this example is a positive for clarity, since we’re asking the requestor to pass in the very structure they want to be created.
template IssuerRequest
with
issuer: Issuer
reason: Text
where
-- ...
controller issuer.userAdmin can
GrantIssuerRights: ContractId Issuer
do create issuer
And so we have come full circle. create
is not syntax, and is perfectly happy to operate on an existing variable of type Issuer
. The contract itself does not exist until GrantIssuerRights
is exercised, even though the record structure does exist.
Interacting from UIs and other ledger clients
Changing the structure of contracts also changes how you interact with those contracts from JSON API, Java codegen, and any other ledger client. Again, the possible values do not change, but their shape does.
For example, our fully-nested Receipt
looks like this in JSON:
{
"claim": {
"pymt": {
"amount": "42",
"currency": "CHF",
"from": "Alice",
"to": "Bob",
"reference": ""
},
"transactionId": "abcdefg"
},
"received": "2021-05-18T22:08:22.892Z"
}
Just as Daml code that interacts with receipts may need changes to deal with a restructuring, your TypeScript code cannot pretend that this is the flat structure that the original Receipt
encoded to JSON as.
Fortunately, just like Daml, TypeScript has good tools for unpacking JS objects used as records. Similarly, if you are using the TypeScript codegen to generate type annotations for your Daml code, the type-checker can guide you to references that need to be updated to reflect the factored structure. Plain JavaScript users will not be so lucky; this is the least of reasons that I strongly recommend all JavaScript developers writing Daml integrations incorporate TypeScript into their workflow.
Further nesting with your own records
As @Levente_Barczy said, serious Daml projects will likely include record types defined with the data
keyword, containing whatever fields you want, and not simply rely on templates’ associated record types to clean up duplication. All of the techniques I’ve demonstrated above—packing, unpacking, nested updating—work just as well with your own record type definitions.
For example, my local copy of the nft project has a TokenKey
data type, which has some nice hygiene and readability properties. But I will leave that sort of experimentation to your own Daml projects.