Export your Crystal types to Typescript?

If you’re using Crystal and Typescript, probably you have some structs/classes/enums that would benefit from a matching typescript definition file. Has anymore made anything for this? Maybe automatically generate a typescript definition file? The benefit being that you don’t want to maintain a file that is just a copy of information in your crystal files and would need to be synced.

https://app.quicktype.io/ Is something that comes to mind, but not exactly what you’re thinking of.

3 Likes

Sounds like you might be thinking along the lines of Crystal objects that you serialize into a JSON response and the TypeScript app on the other end of the request needs to be able to deserialize them in a type-safe way.

If that’s your use case, you may want to define the abstract type using something like OpenAPI and generate both Crystal and TypeScript types from that. You might be able to use this Crystal code generator for OpenAPI.

So I’ve got this prototype written:

require "json"

EOL = "\n"

TypeScriptTypeMap = {
  Int32.name => "number",
  String.name => "string",
  Nil.name => "null",
} of String => String

def typenode_to_typescript_type_string(value : Array(::Class)): String
  return value.sort {|a, b| 
    # pp ({:a => a, :b => b, :c => a==Nil, :d => a==String})
    next -1 if b == Nil
    next a.name <=> b.name
  }.map {|v| typenode_to_typescript_type_string(v) }.join " | "
end

def typenode_to_typescript_type_string(value : ::Class): String
  TypeScriptTypeMap[value.name]? || value.name.gsub({
    '(' => '<',
    ')' => '>',
  } of Char => Char)
end

class Bar
  include JSON::Serializable
  property id : Int32
  property dname : String?
  def initialize(@id : Int32, @foo_list : String? = nil)
  end
end

class Foo
    include JSON::Serializable
    property id : Int32
    property dname : String?
    property bars : Array(Bar)
  
  def initialize(@id : Int32, @foo_list : String? = nil, @bars = Array(Bar).new)
  end

  def get_tsdef(io : IO)
    io << "// #{{{ @type.name.stringify.downcase }}}.ts" << EOL
    io << "export type #{{{ @type.name }}} = {" << EOL
    {% for value, idx in @type.instance_vars %}
      io << " "*2 << {{ value.name.stringify }} << " : " << typenode_to_typescript_type_string({{ value.type.union? ? value.type.union_types : value.type }}) << "," << EOL
    {% end %}
    io << "}" << EOL
    nil
  end
end

Foo.new(id: 50).get_tsdef(STDOUT)

output:

// foo.ts
export type Foo = {
  id : number,
  dname : string | null,
  bars : Array<Bar>,
  foo_list : string | null,
}

The main problem, which is almost aesthetic, is that I would like Foo.new(id: 50).get_tsdef(STDOUT) to be more like get_tsdef(Foo, STDOUT). I couldn’t get any thing like instance_vars to return a non-zero length array.

What were you doing exactly? This seems to work:

def get_tsdef(_klass : T.class, io : IO) forall T
	io << "// #{{{ T.name.stringify.downcase }}}.ts" << EOL
    io << "export type #{{{ T.name }}} = {" << EOL
    {% for value, idx in T.instance_vars %}
      io << " "*2 << {{ value.name.stringify }} << " : " << typenode_to_typescript_type_string({{ value.type.union? ? value.type.union_types : value.type }}) << "," << EOL
    {% end %}
    io << "}" << EOL
end


get_tsdef Foo, STDOUT
1 Like

Just dumping the latest version of this here, enjoy comments and secrets.

require "json"
EOL = "\n"

annotation TypeScriptComment
end

TypeScriptTypeMap = {
  Int32.name  => "number",
  String.name => "string",
  Nil.name    => "null",
} of String => String

def typenode_to_typescript_type_string(value : Array(::Class)) : String
  return value.sort { |a, b|
    # pp ({:a => a, :b => b, :c => a==Nil, :d => a==String})
    next -1 if b == Nil
    next a.name <=> b.name
  }.map { |v| typenode_to_typescript_type_string(v) }.join " | "
end

def typenode_to_typescript_type_string(value : ::Class) : String
  TypeScriptTypeMap[value.name]? || value.name.gsub({
    '(' => '<',
    ')' => '>',
  } of Char => Char)
end

@[TypeScriptComment("bar.ts")]
class Bar
  include JSON::Serializable
  # id property
  property id : Int32
  @[TypeScriptComment("dname is short for display name")]
  property dname : String?
  @[TypeScriptComment("the abc3 property is important for our customers")]
  property abc3 : String?

  def initialize(@id : Int32)
  end
end

@[TypeScriptComment("foo.ts")]
@[TypeScriptComment("Foo is about it")]
class Foo
  include JSON::Serializable
  property id : Int32
  @[TypeScriptComment("dname is short for display name")]
  property dname : String?
  property bars : Array(Bar)
  @[JSON::Field(ignore: true)]
  property secrets : Array(String)?

  def initialize(@id : Int32, @bars = Array(Bar).new)
  end
end

def get_tsdef(_klass : T.class, io : IO) forall T
  {% if T.annotations(TypeScriptComment).size > 1 %}
    io << "/**" << EOL
    {% for ann, idx in T.annotations(TypeScriptComment) %}
      # io  << "Annotation {{ idx }} = {{ ann[0].id }}"
      io << " * " << "{{ ann[0].id }}" << EOL
    {% end %}
    io << " */" << EOL
  {% elsif T.annotations(TypeScriptComment).size == 1 %}
    {% for ann, idx in T.annotations(TypeScriptComment) %}
      io << "// " << "{{ ann[0].id }}" << EOL
    {% end %}
  {% end %}
  io << "export type #{{{ T.name }}} = {" << EOL
  indent = " "*2
  {% for value, idx in T.instance_vars %}
    {% unless value.annotations(JSON::Field).select { |jf| jf[:ignore] || jf[:ignore_serialize] }.size > 0 %}
      {% if value.annotations(TypeScriptComment).size > 1 %}
        io << indent << "/**" << EOL
        {% for ann, idx in value.annotations(TypeScriptComment) %}
          # io  << indent << "Annotation {{ idx }} = {{ ann[0].id }}"
          io << indent << " * " << "{{ ann[0].id }}" << EOL
        {% end %}
        io << indent << " */" << EOL
      {% elsif value.annotations(TypeScriptComment).size == 1 %}
        {% for ann, idx in value.annotations(TypeScriptComment) %}
          io << indent << "// " << "{{ ann[0].id }}" << EOL
        {% end %}
      {% end %}
      io << indent << {{ value.name.stringify }} << " : " << typenode_to_typescript_type_string({{ value.type.union? ? value.type.union_types : value.type }}) << ","

      io << EOL
    {% end %}
  {% end %}
  io << "}" << EOL
end

get_tsdef Bar, STDOUT
get_tsdef Foo, STDOUT

output

// bar.ts
export type Bar = {
  id : number,
  // dname is short for display name
  dname : string | null,
  // the abc3 property is important for our customers
  abc3 : string | null,
}
/**
 * foo.ts
 * Foo is about it
 */
export type Foo = {
  id : number,
  // dname is short for display name
  dname : string | null,
  bars : Array<Bar>,
}

changes:

  • comments can be written in the crystal and still be exported to typescript.
  • the exporter will skip properties that JSON.Field says to ignore.
1 Like