Crystal vs TypeScript

Created simple plotting API for Vega Lite in TypeScript

plot()
  .mark('line')
  .x({ field: 'date', type: 'temporal' })
  .y('price')
  .color('stock_symbol')
  .data(price_data)
  .inspect()

and Crystal

plot
  .mark(:line)
  .x(field: "date", type: :temporal)
  .y("price")
  .color("stock_symbol")
  .data(price_data)
  .inspect()

Full sources at the end of the page.

Informal comparison

I know Ruby and JavaScript / TypeScrpt quite well and decided to try Crystal. It's very good, there are very few things that in my opinion need to be improved.

Working with Crystal - flexible and nicely designed standard library and runtime with clean error messages feels really good - compared to node.js or Browser with TypeScript.

Enums - very bad decision, it should be deprecated and killed as soon as possible, replaced with literal types. Enums are ancient way to achieve type safety by restricting values to some set. It should be handled by type system.

Data manipulation could be better. Crystal has Maps, Tuples, NamedTuples, Records, Struct, yet it feels less flexible and powerfull than Array and Object (including nested Objects) constructors and destructors in TypeScript.

TypeScript advantages

  • Excellent type safety, all the flexible stuff is checked and validated.
  • Excellent type inference and autocasting helps keep code clean from type noise.
  • First-class functions, without proper overloading or multiple dispatch, but still good enough.
  • Flexible and simple data manipulations with array and map constructors and destructors.
  • Flexible and safe metaprogramming with data and functions.
  • Very flexible type system.
  • Clean and compact code, not best, but good enough.
  • Flexibility to work in Object Oriented or Functional style.

Disadvantages:

  • No pattern matching.
  • No easy function or method overloading.
  • async functions, you always need to know if function is ordinary or async.
  • Bad error messages.
  • Escaping async errors.
  • Weak runtime without multicore and huge memory consumption.
  • Weak and fundamentally broken standard library.

Crystal advantages

  • Excellent type safety, all the flexible stuff is checked and validated.
  • Good enough type inference and autocasting, helps keep code clean from type noise (not as good as in TS).
  • Pattern matching.
  • Flexible and safe metaprogramming with macros.
  • Nice and compact code.
  • Method overloading based on types.
  • Good error messages.
  • Good, clean and rich standard library.
  • Good runtime with small memory footprint and parallelism.

Disadvantages:

  • No first class functions, there's distinction between methods and proc.
  • No literal types, instead Enums are used, which are harder, inconvenient and broken in some cases.
  • Can't be used in functional style, Object Oriented style only.
  • Data manipulation could be better, no flexible constructors and destructors for data manipulation.

Sources

TypeScript

type something = any

export type Mark = 'point' | 'circle' | 'line' | 'bar' | 'tick'

export type FieldType = 'nominal' | 'ordinal' | 'quantitative' | 'temporal'

export type Channel = 'x' | 'y' | 'size' | 'color'

export interface Axis {
  title?:  string | boolean
  labels?: boolean
}

export interface Encoding {
  field:  string
  type:   FieldType
  axis?:  Axis
}

export interface VegaSpec {
  mark?:     Mark
  encoding?: { [key in Channel]?: Encoding }
}

function build_encoding_helper(channel: Channel) {
  return function encoding_helper(this: Plot, arg: {
    field:   string,
    type?:   FieldType,
    title?:  string | boolean,
    labels?: boolean
  } | string) {
    const { field, type, title, labels } = typeof arg == 'string' ? {
      field:  arg,
      type:   undefined,
      title:  undefined,
      labels: undefined,
    } : arg

    const axis: Axis = { title: false }
    if (title !== undefined) axis.title = title
    if (labels !== undefined) axis.labels = labels

    this.spec.encoding = this.spec.encoding || {}
    this.spec.encoding[channel] = {
      field,
      type: type || 'quantitative',
      axis
    }
    return this
  }
}

export type RenderFn = (spec: VegaSpec, data: something) => Promise<void>

export class Plot {
  spec:   VegaSpec = {}
  _data?: something

  constructor(
    protected readonly render?: RenderFn
  ) {}

  mark(mark: Mark) {
    this.spec.mark = mark
    return this
  }

  x     = build_encoding_helper('x')
  y     = build_encoding_helper('y')
  color = build_encoding_helper('color')
  size  = build_encoding_helper('size')

  data(data : something) {
    this._data = data
    return this
  }

  inspect(): Promise<void> {
    if (!this.render) throw new Error("Render not defined")
    return this.render(this.spec, this._data)
  }
}

const plot = () => new Plot(async (spec, data) => console.log({ spec, data }))
const price_data = "price data"

// Usage --------------------------------------------------------------------------
plot()
  .mark('line')
  .x({ field: 'date', type: 'temporal' })
  .y('price')
  .color('stock_symbol')
  .data(price_data)
  .inspect()

Crystal

require "json"

class Plot
  enum Mark
    Point; Circle; Line; Bar; Tick

    def to_s; super.downcase end
    # Equality needed to use `plot.mark.should eq :line` in specs
    def ==(symbol : Symbol); to_s == symbol.to_s end
    def to_json(json : JSON::Builder); json.string to_s end
  end

  enum FieldType
    Nominal; Ordinal; Quantitative; Temporal

    def to_s; super.downcase end
    def ==(symbol : Symbol); to_s == symbol.to_s end
    def to_json(json : JSON::Builder); json.string to_s end
  end

  alias Axis = NamedTuple(
    title:  String | Bool,
    labels: Bool
  )

  alias Encoding = NamedTuple(
    field: String,
    type:  FieldType,
    axis:  Axis
  )

  alias VegaSpec = NamedTuple(
    mark:     Mark,
    encoding: Hash(Symbol, Encoding)
  )

  @spec : VegaSpec = {
    mark:     Mark::Point,
    encoding: Hash(Symbol, Encoding).new
  }
  # `Any` can"t be used, so the data stored as JSON string
  @json_data : String? = nil

  @render : (VegaSpec, String) -> Void

  def initialize(&render : (VegaSpec, String) -> Void)
    @render = render
  end

  def initialize
    @render = ->(plot : RenderData){ raise "render not implemented" }
  end

  def mark(mark : Mark)
    @spec = @spec.merge mark: mark
    self
  end

  # Macros should be used to define "x", "y", "size", "color", but I don"t know how to write it
  def x(
    field :  String,
    type :   FieldType = :quantitative,
    title :  String | Bool = false,
    labels : Bool = true
  )
    @spec[:encoding][:x] = { field: field, type: type, axis: { title: title, labels: labels }}
    self
  end

  def y(
    field : String,
    type :  FieldType = :quantitative,
    title :  String | Bool = false,
    labels : Bool = true
  )
    @spec[:encoding][:y] = { field: field, type: type, axis: { title: title, labels: labels }}
    self
  end

  def color(
    field : String,
    type :  FieldType = :quantitative,
    title :  String | Bool = false,
    labels : Bool = true
  )
    @spec[:encoding][:color] = { field: field, type: type, axis: { title: title, labels: labels }}
    self
  end

  def data(data : Data) forall Data
    @json_data = data.to_json
    self
  end

  def inspect()
    json_data = @json_data
    raise "data not provided" unless json_data
    @render.call @spec, json_data
  end
end

def plot
  Plot.new{|spec, data| p spec, data }
end
price_data = "price data"

# Usage --------------------------------------------------------------------------
# Braces can't be omited
plot
  .mark(:line)
  .x(field: "date", type: :temporal)
  .y("price")
  .color("stock_symbol")
  .data(price_data)
  .inspect()