Overview

Typeshare is a tool for generating type definitions in other languages based on type definitions in Rust. Specifically, Typeshare is useful for FFI where types are passed as fully serialized blobs, and then decoded on the other side.

Typeshare was originally developed as an internal tool at 1Password, but has been released as an open-source crate that you can use and contribute to.

This guide describes how to install and use Typeshare effectively.

Installation

The easiest way to install the Typeshare CLI is with cargo. Just run the following command:

cargo install typeshare-cli

Once you have the CLI installed, you then need to annotate the rust types that you want to generate FFI definitions for. In order to be able to use the #[typeshare] annotation, you will need to add typeshare as a dependency to your project's Cargo.toml.

# Cargo.toml

[dependencies]
typeshare = "1.0.0" # Use whichever version is the most recent

Usage

This section will cover how to use Typeshare to generate type definitions for your project and how to interact with them across FFI.

Typeshare provides a CLI tool that will perform the necessary generation for any types you annotate in your Rust code.

To generate ffi definitions for a specific target language, run the typeshare command and specify the directory containing your rust code, the language you would like to generate for, and the file to which your generated definitions will be written:

typeshare ./my_rust_project --lang=kotlin --output-file=my_kotlin_definitions.kt
typeshare ./my_rust_project --lang=swift --output-file=my_swift_definitions.swift
typeshare ./my_rust_project --lang=typescript --output-file=my_typescript_definitions.ts
typeshare ./my_rust_project --lang=scala --output-file=my_scala_definitions.scala

The first command-line argument is the name of the directory to search for Rust type definitions. The CLI will search all files in the specified directory tree for annotated Rust types. In addition to the input directory, you will also need to specify your desired target language and the output file to which the generated types will be written. This is done with the --lang and --output-file options respectively.

The currently supported output languages are:

  • Kotlin
  • Typescript
  • Swift
  • Scala
  • Go

If your favourite language is not in this list, consider opening an issue to request it or try implementing it yourself! See our contribution guidelines for more details.


In the following sections, we will learn how to customize the behaviour of Typeshare using the provided #[typeshare] attribute and configuration options.

Annotations

Annotating Types

Add the #[typeshare] attribute to any struct or enum you define to generate definitions for that type in the selected output language.

#![allow(unused)]
fn main() {
#[typeshare]
struct MyStruct {
    my_name: String,
    my_age: u32,
}

#[typeshare]
enum MyEnum {
    MyVariant,
    MyOtherVariant,
    MyNumber(u32),
}
}

Annotation arguments

We can add arguments to the #[typeshare] annotation to modify the generated definitions.

Decorators

It can be used to add decorators like Swift protocols or Kotlin interfaces to the generated output types. For example, we can do

#![allow(unused)]
fn main() {
#[typeshare(swift = "Equatable, Codable, Comparable, Hashable")]
#[serde(tag = "type", content = "content")]
pub enum BestHockeyTeams {
    MontrealCanadiens,
    Lies(String),
}
}

and this will produce the following Swift definition:

public enum OPBestHockeyTeams2: Codable, Comparable, Equatable, Hashable {
	case montrealCanadiens
	case lies(String)

	enum CodingKeys: String, CodingKey, Codable {
		case montrealCanadiens = "MontrealCanadiens",
			lies = "Lies"
	}

	private enum ContainerCodingKeys: String, CodingKey {
		case type, content
	}

	public init(from decoder: Decoder) throws {
		let container = try decoder.container(keyedBy: ContainerCodingKeys.self)
		if let type = try? container.decode(CodingKeys.self, forKey: .type) {
			switch type {
			case .montrealCanadiens:
				self = .montrealCanadiens
				return
			case .lies:
				if let content = try? container.decode(String.self, forKey: .content) {
					self = .lies(content)
					return
				}
			}
		}
		throw DecodingError.typeMismatch(OPBestHockeyTeams2.self, DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Wrong type for OPBestHockeyTeams"))
	}

	public func encode(to encoder: Encoder) throws {
		var container = encoder.container(keyedBy: ContainerCodingKeys.self)
		switch self {
		case .montrealCanadiens:
			try container.encode(CodingKeys.montrealCanadiens, forKey: .type)
		case .lies(let content):
			try container.encode(CodingKeys.lies, forKey: .type)
			try container.encode(content, forKey: .content)
		}
	}
}

Serialize as Another Type

You can also use the serialized_as argument to tell Typeshare to treat the serialized type as another Rust type. This is usually combined with custom serde attributes.

#![allow(unused)]
fn main() {
/// Options that you could pick
#[typeshare(serialized_as = "String")]
#[serde(try_from = "String", into = "String")]
pub enum Options {
    /// Affirmative Response
    Yes,
    No,
    Maybe,
    /// Sends a string along
    Cool(String),
}
}

This would generate the following Kotlin code:

/// Options that you could pick
typealias Options = String

The #[serde] Attribute

Since Typeshare relies on the serde crate for handling serialization and deserialization between Rust types and the generated foreign type definitions, we can use the annotations provided by serde on our Typeshare types. For example, the following Rust definition

#![allow(unused)]
fn main() {
/// This is a comment.
/// Continued lovingly here
#[typeshare]
#[serde(rename_all = "camelCase")]
pub enum Colors {
    Red = 0,
    Blue = 1,
    /// Green is a cool color
    #[serde(rename = "green-like")]
    Green = 2,
}
}

will become the following Typescript definition.

/**
 * This is a comment.
 * Continued lovingly here
 */
export const enum Colors {
	Red = "red",
	Blue = "blue",
	/** Green is a cool color */
	Green = "green-like",
}

Skipping Fields

Within a Rust type, there may be fields or variants that you want Typeshare to ignore. These can be skipped using either the #[serde(skip)] annotation or the #[typeshare(skip)] annotation. For example, this Rust type

#![allow(unused)]
fn main() {
#[typeshare]
pub struct MyStruct {
    a: i32,
    #[serde(skip)]
    b: i32,
    c: i32,
    #[typeshare(skip)]
    d: i32,
}
}

becomes the following Typescript definition.

export interface MyStruct {
	a: number;
	c: number;
}

Configuration

The behaviour of Typeshare can be customized by either passing options on the command line or in a configuration file. For any command line option that corresponds to a value in the configuration file, specifying the option on the command line will override the value in the configuration file.

Command Line Options

  • -l, --lang (Required) The language you want your definitions to be generated in. Currently, this option can be set to either kotlin, swift, go, or typescript.

  • -o, --output-file (Required) The file path to which the generated definitions will be written.

  • -s, --swift-prefix Specify a prefix that will be prepended to type names when generating types in Swift.

  • -M, --module-name Specify the name of the Kotlin module for generated Kotlin source code.

  • -j, --java-package Specify the name of the Java package for generated Kotlin types.

  • -c, --config-file Instead of searching for a typeshare.toml file, this option can be set to specify the path to the configuration file that Typeshare will use.

  • -g, --generate-config-file Instead of running Typeshare with the provided options, generate a configuration file called typeshare.toml containing the options currently specified as well as default configuration parameters.

  • --directories A list argument that you can pass any number of glob patterns to. All folders and files given will be searched recursively, and all Rust sources found will be used to create a singular language source file.

  • --go-package The name of the Go package for use with building for Go. This will be included in the header of the output file. This option will only be available if typeshare-cli was built with the go feature.

Configuration File

By default, Typeshare will look for a file called typeshare.toml in your current directory or any of its parent directories. Typeshare configuration files will look like this:

[swift]
prefix = 'MyPrefix'

[kotlin]
module_name = 'myModule'
package = 'com.example.package'

[swift.type_mappings]
"DateTime" = "Date"

[typescript.type_mappings]
"DateTime" = "string"

[kotlin.type_mappings]
"DateTime" = "String"

In the configuration file, you can specify the options you want to set so that they do not need to be specified when running Typeshare from the command line. You can also define custom type mappings to specify the foreign type that a given Rust type will correspond to.

In order to create a config file you can run the following command to generate one in your current directory.

typeshare -g

Contributing to Typeshare

Thank you! If you would like to contribute to the project, go to the GitHub repository. There, you can open issues to report bugs and request features.

If you want to contribute code, please send a GitHub Pull Request to the repository with a clear list of your changes and the reasons for the changes. Before contributing, you should probably familiarize yourself with the internal documentation, if you haven't already. Make sure to look through the READMEs for each part of Typeshare you plan to contribute to, and double-check the Rust documentation on docs.rs if you want to know more about the API. And feel free to open an issue if you want to ask the 1Password team directly, or if the documentation is unclear.

For larger changes, please open an RFC issue first in order to discuss the broader impacts of the proposed change with the other maintainers.