LambdaBuffers to Typescript
This chapter will walk through a translation from a LambdaBuffers' module into a Typescript module.
To demonstrate this, we will use the lbf-prelude-to-typescript
CLI tool which is just a convenient wrapper over the raw lbf
CLI.
To this end, we may enter a development shell which provides this tool along with many other Lambda Buffers CLI tools with the following command.
$ nix develop github:mlabs-haskell/lambda-buffers#lb
$ lbf<tab>
lbf lbf-plutus-to-haskell lbf-plutus-to-rust lbf-prelude-to-haskell lbf-prelude-to-rust
lbf-list-modules-typescript lbf-plutus-to-purescript lbf-plutus-to-typescript lbf-prelude-to-purescript lbf-prelude-to-typescript
Or, we may directly refer to the lbf-prelude-to-typescript
CLI with the following command.
nix run github:mlabs-haskell/lambda-buffers#lbf-prelude-to-typescript
In this chapter, we will use the former option.
Consider the Document.lbf schema which we may recall is as follows.
module Document
-- Importing types
import Prelude (Text, List, Set, Bytes)
-- Author
sum Author = Ivan | Jovan | Savo
-- Reviewer
sum Reviewer = Bob | Alice
-- Document
record Document a = {
author : Author,
reviewers : Set Reviewer,
content : Chapter a
}
-- Chapter
record Chapter a = {
content : a,
subChapters : List (Chapter a)
}
-- Some actual content
sum RichContent = Image Bytes | Gif Bytes | Text Text
-- Rich document
prod RichDocument = (Document RichContent)
We generate the corresponding Typescript code with the following commands.
$ nix develop github:mlabs-haskell/lambda-buffers#lb
$ lbf-list-modules-typescript lbf-document=. > lb-pkgs.json
$ lbf-prelude-to-typescript --gen-opt="--packages lb-pkgs.json" Document.lbf
$ find autogen/
autogen/
autogen/LambdaBuffers
autogen/LambdaBuffers/Document.mts
autogen/build.json
The generated autogen
directory created contains the generated Typescript modules.
Note that lbf-list-modules-typescript
is needed to create a JSON object which maps package names (for NPM) to Lambda Buffers' modules.
Thus, in this example, one should have a package.json
file which associates the key "name"
with the string value "lbf-document"
.
The autogen/build.json
file can be ignored.
The file autogen/LambdaBuffers/Document.mts
contains the outputted Typescript module:
// @ts-nocheck
import * as LambdaBuffers$Document from './Document.mjs'
import * as LambdaBuffers$Prelude from './Prelude.mjs'
export type Author = | { name : 'Ivan' }
| { name : 'Jovan' }
| { name : 'Savo' }
export const Author : unique symbol = Symbol('Author')
export type Chapter<$a> = { content : $a
, subChapters : LambdaBuffers$Prelude.List<Chapter<$a>>
}
export const Chapter : unique symbol = Symbol('Chapter')
export type Document<$a> = { author : Author
, reviewers : LambdaBuffers$Prelude.Set<Reviewer>
, content : Chapter<$a>
}
export const Document : unique symbol = Symbol('Document')
export type Reviewer = | { name : 'Bob' } | { name : 'Alice' }
export const Reviewer : unique symbol = Symbol('Reviewer')
export type RichContent = | { name : 'Image'
, fields : LambdaBuffers$Prelude.Bytes
}
| { name : 'Gif'
, fields : LambdaBuffers$Prelude.Bytes
}
| { name : 'Text'
, fields : LambdaBuffers$Prelude.Text
}
export const RichContent : unique symbol = Symbol('RichContent')
export type RichDocument = Document<RichContent>
export const RichDocument : unique symbol = Symbol('RichDocument')
Product types
The type RichDocument
have been declared as a product type in the LambdaBuffers schema using the prod
keyword.
In general, product types are mapped to tuple types in Typescript most of the time. The exception is if there is only one element in the tuple in which case the type is translated to a type alias.
More precisely, given a LambdaBuffers' product type as follows
prod MyProduct = SomeType1 ... SomeTypeN
where the ...
denotes iterated SomeTypei
for some i
, then
-
If
N = 0
soprod MyProduct =
, then we map this to the Typescript typeexport type MyProduct = []
-
If
N = 1
soprod MyProduct = SomeType1
, then we map this to the Typescript typeexport type MyProduct = SomeType1
i.e.,
MyProduct
simply aliasesSomeType1
-
If
N >= 2
soprod MyProduct = SomeType1 ... SomeTypeN
, then we map this to the Typescript typeexport type MyProduct = [SomeType1, ..., SomeTypeN]
i.e.,
MyProduct
is a tuple with a fixed number of elements with known types.
Sum types
The types Author
, Reviewer
, and RichContent
have been declared as sum types in the LambdaBuffers schema using the sum
keyword.
In general, sum types are mapped to a union type in Typescript and with the additional following rules. Given a LambdaBuffers' sum type as follows
sum MySum
= Branch1 Branch1Type1 ... Branch1TypeM1
| ...
| BranchN BranchNType1 ... BranchNTypeMN
where the ...
denotes either an iterated Branchi
for some i
, or an iterated BranchiTypej
for some i
and j
, then each branch, say Branchi
is translated as follows.
-
If
Branchi
has no fields i.e.,| Branchi
, then the corresponding Typescript type's union member is| { name: 'Branchi' }
-
If
Branchi
has one or more fields i.e.,| Branchi BranchiType1 ... BranchiTypeMi
, then the corresponding Typescript type's union member is| { name: 'Branchi' , fields: <Product translation of BranchiType1 ... BranchiTypeMi> }
where
<Product translation of BranchiType1 ... BranchiTypeMi>
denotes the right hand side of the product translation ofprod FieldsProduct = BranchiType1 ... BranchiTypeMi
.So, for example, given
| Branchi BranchiType1
, the corresponding Typescript type is as follows| { name: 'Branchi' , fields: BranchiType1 }
And given
| Branchi BranchiType1 BranchiType2
, the corresponding Typescript type is as follows.| { name: 'Branchi' , fields: [BranchiType1, BranchiType2] }
Record types
The types Document
and Chapter
have been declared as record types in the LambdaBuffers schema using the record
keyword.
Record types are mapped to object types in Typescript. Given a LambdaBuffers' record type as follows
record MyRecord = { field1: SomeType1, ..., fieldN: SomeTypeN }
where ...
denotes iterated fieldi: SomeTypei
for some i
, the corresponding Typescript type is
type MyRecord = { field1: SomeType1, ..., fieldN, SomeTypeN }
Type classes quickstart
Typescript has no builtin implementation of type classes. As such, LambdaBuffers rolled its own type classes. A complete usage example can be found in the Typescript Prelude sample project, but assuming the packaging is setup correctly, the interface to use a typeclass is as follows
import * as LbrPrelude from "lbr-prelude";
// In Haskell, this is `10 == 11`
LbrPrelude.Eq[LbrPrelude.Integer].eq(10n, 11n) // false
// In Haskell, this is `Just 3 == Nothing`
LbrPrelude.Eq[LbrPrelude.Maybe](LbrPrelude.Eq[LbrPrelude.Integer])
.eq( { name: 'Just', fields: 3 }
, { name: 'Nothing' }) // false
In particular, we access a global variable LbrPrelude.Eq
which contains the type class instances, and pick out a particular instance with the type's name like LbrPrelude.Integer
. Note that the LbrPrelude.Maybe
instance requires knowledge of the Eq
instance of the LbrPrelude.Integer
, so we must pass that in as a function argument.
Type classes in detail
A type class in Typescript is an object type which defines a set of methods.
For example, the Eq
type class in Haskell defines the set of methods ==
(equality) and /=
(inequality) as follows.
class Eq a where
(==) :: a -> a -> Bool
(/=) :: a -> a -> Bool
The corresponding Eq
class in Typescript is:
export interface Eq<A> {
readonly eq: (l: Readonly<A>, r: Readonly<A>) => boolean;
readonly neq: (l: Readonly<A>, r: Readonly<A>) => boolean;
}
Each type class in Typescript must have an associated global variable which maps unique representations of its instance types to the corresponding object of the type class implementation.
For example, the Eq
type class has the global variable defined in the lbr-prelude library defined as follows
export const Eq: EqInstances = { } as EqInstances
where EqInstances
is an interface type that is initially empty but will be extended with instances of types later.
export interface EqInstances { }
Finally, the following invariant is maintained in the code generator:
- Every type
T
has an associated unique symbol also calledT
.
So, the type Integer
has
export type Integer = bigint
export const Integer: unique symbol = Symbol('Integer')
and implementing its Eq
instance amounts to the following code.
export interface EqInstances {
[Integer]: Eq<Integer>
}
Eq[Integer] = { eq: (l,r) => l === r
, neq: (l,r) => l !== r
}
For types defined in the LambdaBuffers schema, this chunk of code will be automatically generated provided there is an appropriate derive
construct.
Type instances with constraints
Recall in Haskell that the Eq
instance for a tuple may be defined as follows
instance (Eq a, Eq b) => Eq (MyPair a b) where
MyPair a1 a2 == MyPair b1 b2 = a1 == b1 && a2 == b2
MyPair a1 a2 != MyPair b1 b2 = a1 != b1 || a2 != b2
The corresponding Typescript type definition and instance would be defined as follows
export type MyPair<a, b> = [a, b]
export const MyPair: unique symbol = Symbol('MyPair')
export interface EqInstances {
[MyPair]: <A,B>(a : Eq<A>, b : Eq<B>) => Eq<MyPair<A,B>>
}
Eq[MyPair] = (dictA, dictB) => { return { eq: (a,b) => dictA.eq(a[0], b[0]) && dictB.eq(a[1], b[1])
, neq: (a,b) => dictA.neq(a[0], b[0]) || dictB.neq(a[1], b[1])
}
}
Note that the constraints (Eq a, Eq b) =>
become arguments dictA
and dictB
that are used to construct the Eq
instance for MyPair
.
This loosely follows the original translation given in the paper How to make ad-hoc polymorphism less ad hoc with some minor modifications.
Limitations
-
Only Haskell 2010 typeclasses are supported for the Typescript code generator. So, the following schemas will probably generate incorrect code.
derive Eq (MyPair a a) derive Eq (MyMaybe (MyMaybe Integer)) derive Eq (MyMaybe Integer)