@lanetix/type-visitor

visit your types

Usage no npm install needed!

<script type="module">
  import lanetixTypeVisitor from 'https://cdn.skypack.dev/@lanetix/type-visitor';
</script>

README

@lanetix/type-visitor

Installation

npm install -S @lanetix/type-visitor

Definitions

  • prim = primitive type = type function that takes no args
  • varop = variatic type = type function that takes >1 arg (such as a set of other types)
  • unop = uniary type = type function that takes only 1 arg

Lx types (system fields) that exist on every record type (except Lx users). Not nullable, therefor never wrapped in tyfun.option

  • name = string prim
  • created_at = date prim
  • updated_at = date prim
  • owner_id = id prim

Primitive Types

tyfun.unit

  • tyfun.unit are values which are empty
  • {}

tyfun.decimal

tyfun.id

tyfun.integer

tyfun.date

Uniary Types

tyfun.option

  • always wraps another type
  • nullable -- is either null, or it’s type. There is no concept of default values.
  • type-checked will be “if optional, traverse down and see what type you are”. See child, and bring back up value in the acc
  • The Postgres db interface wraps all nullable fields in tyfun.option. If a type shows up in the GUI and it's not wrapped in tyfun.option, then it's not nullable.

tyfun.list

  • Only 1 type allowed in a list
  • But type can be complex (json, list of lists)

Variadic Types

tyfun.sum

  • Bools are treated as sum types whose value is one tyfun.unit (true or false)
  • The value in the field is only a single value, but the type is of any of the possible types defined
  • In tyfun.sum there will be multiple tags each mapped to a type - { tag: type, tag: type }, but in any instance of this type only one tag will have an associated value

tyfun.product

  • Tuples with labels k:v
  • Values are of different types, but are defined types
  • can mutate with additive changes { k1:int, k2:string } —> { k1:int, k2:string, k3:int }
  • record types themselves are products

More about sum and product types

Usage

require @lanetix/type-visitor and you get access to the following

tyFun

An object of Type functions / type constructors. These are the "types of types" that Lanetix supports internally. When writing a type of the form: { fun: fun, args: args }, the fun part should be one of these constants.

visitContext

An object of visit contexts. These provide information about the parent node of the current node in a visitor. For example, if a visitor is called on the root node, the provided context (ctx) will have the tag visitContext.top. If its parent is a list node (so this is a "list of current node"), ctx.tag will be visitContext.listChild, etc.

For everything but visitContext.top, ctx also incorporates a parent attribute, which holds the context that was previously passed to the parent of the current node. For sumChild and productChild, the context also incorporates a label (representing the variant and field name used to construct / access the current node from an instance of the parent type) and a siblings attribute, which allows access to all the variants (or field names) at this level.

For instance, in the following type:

{ fun: tyfun.sum, args: { foo: { fun: { tyfun.unit } } } }
^                                ^
A                                B

The outermost node, A, would receive

ctx: { tag: visitContext.top }

when visited, and the innermost node, B, would receive

ctx: {
  tag: visitContext.sum,
  label: 'foo',
  parent: { tag: visitContext.top },
  siblings: { foo: { fun: { tyfun.unit } } }
}

For more information on this sort of scheme for representing tree context (usually called a "zipper") see https://en.wikipedia.org/wiki/Zipper_(data_structure).

simpleVisitor

A convenience function that simplifies the process of making certain types of visitors by only requiring you to specify three callbacks: one for primitives (0-arity type functions), one for unary operators (option and list), and one for variadic operators (sum and product). For the most part it works identically to an ordinary visitor, except that it passes the type function as well as the context (in a regular visitor, the type function is implied, so it isn't passed in as an argument).

visit

The visit function is intended to provide code reuse for the common task of traversing a type tree. It is a curried function of two arguments. The first is a dictionary of callback functions of three forms:

  • for primitives: (acc, ctx)
  • for unops: (down, acc, {arg, ctx})
  • for varops: (down, acc, {args ctx})

ctx is as discussed above in visitContext. arg (for unops) is the type argument they take; for instance, for { fun: tyfun.option, args: { fun: tyfun.unit } }, the root node will receive the parameter arg: { fun: tyfun.unit }. args (for varops) is the same thing for variadic operators; because variadic operator arguments are always labeled, args is a dictionary from label names to types, rather than a list. down and acc will be discussed below.

The second curried argument is of the form (acc, ty). The first argument, acc, represents the initial accumulator state; it gets passed to the visit function associated with the type at the root of the type tree ty. If that function is a primitive, whatever it returns is the return value of visit (assuming the type tree was well formed).

For unops and varops, the story is a bit more complicated. The first argument for these type functions is down, which is a callback taking two arguments: the first being the new value of the accumulator (effectively, the return value of the visit on the way down the tree). The second is another callback, which will be called by visit when it hits this node on the way up the tree. The value it is called with is either (if a unary operator) the accumulator returned on the way up for its child node (in the case of primitives, this is just the return value on the way down), or (if a variadic operator) a dictionary of the accumulators on the way up for each child, organized by label.

Because visit validates that the type structure is correct every time it's called, it's not necessary to worry about well-formedness in a visitor. They should generally be used for domain logic. There are examples of visitors throughout this file and examples of valid and invalid type structures can be seen in the type tests.

typeCheck

This function takes a (exp, ty) pair and returns true if the expression matches the type, false otherwise.

const tyfunToJson = fun => {
  const builtin = name => ({ namespace: 'builtin', name })
  switch (fun) {
    case tyfun.unit:
      return builtin('unit')
    case tyfun.id:
      return builtin('id')
    case tyfun.string:
      return builtin('string')
    case tyfun.integer:
      return builtin('integer')
    case tyfun.decimal:
      return builtin('decimal')
    case tyfun.list:
      return builtin('list')
    case tyfun.option:
      return builtin('option')
    case tyfun.sum:
      return builtin('sum')
    case tyfun.product:
      return builtin('product')
    default:
      // should be unreachable since this is only called internally from visit.
      throw new Error(`invalid type function`)
  }
}

const tyfunFromJson = ({ namespace, name }) => {
  if (namespace !== 'builtin') {
    throw new Error(`invalid type function namespace`)
  }
  switch (name) {
    case 'unit':
      return tyfun.unit
    case 'id':
      return tyfun.id
    case 'string':
      return tyfun.string
    case 'integer':
      return tyfun.integer
    case 'decimal':
      return tyfun.decimal
    case 'list':
      return tyfun.list
    case 'option':
      return tyfun.option
    case 'sum':
      return tyfun.sum
    case 'product':
      return tyfun.product
    default:
      throw new Error(`invalid type function name`)
  }
}

toJson

This function takes a type tree and turns it into a JSON structure. The json structure should be considered an opaque implementation detail--it should not be used directly, except in order to pass it into intoTy.

intoTy

Takes a JSON structure created by toJson and parses it into a type tree. It should only be used on JSON that was previously formed through toJson; constructing the JSON directly isn't guaranteed to work.