Typeshare

Typeshare is a tool that supports the synchronisation of type definitions between Rust and other languages.

How we use it in PermaplanT

In our application, Typeshare is used to extract type definitions from the backend Rust code and generate corresponding TypeScript definitions for the frontend. This ensures that both the backend and frontend share the same data structures, reducing the risk of type mismatches and improving overall code consistency and reliability.

Typeshare is integrated in the build scripts for the frontend, so the corresponding types are kept up to date every time the application is built.

Important remarks:

Optional properties

One special situation with the type translation had to be handled additionally in the Frontend code:

  • In Rust, Option properties are defined as either a specific Type or null

  • In Typescript, the equivalent Option property, represented by a ?, is defined as either a specific Type or undefined (in general, Typescript discourages the use of null to represent empty values)

  • As such, the Typeshare generated types in the frontend, when containing Option properties, they get translated as a typescript optional ?.

  • In theory this is not an issue, but because of the way that our data is passed between the backend and frontend through our API, an interesting behaviour can be observed:

    • First, a Backend object with an 'empty' optional property gets serialized (using serde) into a JSON object to be passed through a request to the Frontend. In this process, the value for that empty property gets set to null, according to the type definition in Rust.
    • This object gets received by the API request in the Frontend, where it is typed automatically into the type generated by Typeshare. For an optional property, this means allowing either the specific type or undefined as allowed values.
    • But, due to Type coercion in Typescript/Javascript not really checking the value for each property of the object, in this scenario the property value ends up being null instead of undefined.
    • Similarly, a new Object of that type, if generated by the Frontend code (for example, in a form), will have the empty properties as undefined values. When sending this through a POST/PUT request to the Backend, the Rust code (with serde) will correctly map the undefined value to the null value needed in the Backend type logic.
  • In most cases in the Frontend code, this stray null does not end up being an issue, as we usually check such missing properties with a simple negation (!object.property), and in JS/TS !undefined === !null.

  • The problems arise for the scenarios where we need to compare an old value with a new one, as in JS/TS undefined !== null

  • A specific scenario where this was an issue was in Forms which include optional number properties. This was an issue, as a value loaded from the Backend, as null, would correctly show an empty input field, but that would not be equivalent with an input field that was empty (or for example was emptied by deleting the value). In this latter case, the value is undefined. As such, form validation, as well as change-tracking for that value was causing issues and had to be handled individually.

  • For this, a mapping util function was used, to make sure we would map the value of the object, when loading the form and when tracking changes, to be represented by undefined for all cases that were not a valid numerical input. This would ensure consistency between values as undefined / null / NaN and treat them as equivalent.

BigInt Current State and Possible Improvements

Current implementation

  • Backend entities use 64-bit integers (i64) for the IDs of the maps, seeds, and plants tables, as well as other tables related to them.
  • Before sending DTOs to the frontend, these values are downcast from i64 to i32.
  • This is done because Typeshare does not support types like i64, and i64 cannot be safely represented as a JavaScript number due to its limited integer precision.

Problems with the current approach

  • Data loss / overflow risk
    Downcasting i64 → i32 truncates values outside the i32 range and can silently break behavior for large IDs. Users will be unable to open or create any maps after map ID 2,147,483,647. After this point, -1 will be returned to the frontend, resulting in a map not found error.
  • Limited scalability
    As data grows, more fields may exceed i32, increasing the risk of subtle production issues.

Desired behavior

  • Preserve full 64-bit values end-to-end without precision loss.
  • Make data type constraints explicit at the API boundary.
  • Keep backend DTOs and generated frontend types consistent.

Possible improvements

Serialize as a string

Adopt a string representation for 64-bit integers:

  • Keep Rust DTO fields as i64.
  • Serialize i64 values as JSON strings using Serde.
  • Configure Typeshare to generate a corresponding frontend type (typically string).
  • In the frontend:
    • Treat the value as string if it is only displayed or compared.
    • Convert to bigint (BigInt(value)) when arithmetic is required.

This approach may add minor overhead (string parsing) but avoids precision loss and reduces the risk of unforeseen issues later on.

Keep track of Typeshare improvements

There is currently an open pull request that aims to improve this situation:

It proposes to:

  • Allow users to explicitly override how disallowed types are mapped, on a per-field or per-type basis.
  • Give developers control over how Typeshare represents i64, u64, and similar types in the target language (e.g., TypeScript).
  • Enable projects to define how 64-bit integers should be exposed to the frontend (e.g., as string, bigint, or a custom type), without needing to downcast to i32.

Date handling with NaiveDate and NaiveDateTime

Current implementation

  • Backend DTOs use chrono::NaiveDate and chrono::NaiveDateTime.
  • These values are serialized via Serde and exposed to the frontend as strings through Typeshare.
  • No timezone information is included, matching the semantics of the Naive* types.
  • Currently, chrono::NaiveDateTime is used for fields such as created_at and modified_at. These fields are not yet used by the frontend; they are set exclusively by the backend and the database.

Semantics of the current types

  • NaiveDate
    • Represents a calendar date without time or timezone.
  • NaiveDateTime
    • Represents a local date and time without a timezone.
    • Does not represent an absolute instant in time.
    • Requires an implicit or explicit timezone decision when interpreted.

Problems and risks

  • Ambiguity in the frontend
    • A NaiveDateTime string parsed as a JavaScript Date will implicitly apply the browser’s local timezone, which may lead to unintended time shifts.

Possible backend improvements

  • Standardize and document the serialization format
    • Define the exact formats the API guarantees:
      • NaiveDate: YYYY-MM-DD
      • NaiveDateTime: YYYY-MM-DDTHH:mm:ss(.SSS) (no Z, no offset)
    • Ensure all endpoints follow the same convention, including the policy for fractional seconds.