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,
Optionproperties are defined as either a specific Type ornull -
In Typescript, the equivalent
Optionproperty, represented by a?, is defined as either a specific Type orundefined(in general, Typescript discourages the use ofnullto represent empty values) -
As such, the Typeshare generated types in the frontend, when containing
Optionproperties, 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
undefinedas 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
nullinstead ofundefined. - Similarly, a new Object of that type, if generated by the Frontend code (for example, in a form), will have the empty properties as
undefinedvalues. When sending this through a POST/PUT request to the Backend, the Rust code (with serde) will correctly map theundefinedvalue to thenullvalue needed in the Backend type logic.
- 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
-
In most cases in the Frontend code, this stray
nulldoes 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 isundefined. 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
undefinedfor all cases that were not a valid numerical input. This would ensure consistency between values asundefined/null/NaNand treat them as equivalent.
BigInt Current State and Possible Improvements
Current implementation
- Backend entities use 64-bit integers (
i64) for the IDs of themaps,seeds, andplantstables, as well as other tables related to them. - Before sending DTOs to the frontend, these values are downcast from
i64toi32. - This is done because Typeshare does not support types like
i64, andi64cannot be safely represented as a JavaScriptnumberdue to its limited integer precision.
Problems with the current approach
- Data loss / overflow risk
Downcastingi64 → i32truncates values outside thei32range 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 exceedi32, 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
i64values as JSON strings using Serde. - Configure Typeshare to generate a corresponding frontend type (typically
string). - In the frontend:
- Treat the value as
stringif it is only displayed or compared. - Convert to
bigint(BigInt(value)) when arithmetic is required.
- Treat the value as
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 toi32.
Date handling with NaiveDate and NaiveDateTime
Current implementation
- Backend DTOs use
chrono::NaiveDateandchrono::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::NaiveDateTimeis used for fields such ascreated_atandmodified_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
NaiveDateTimestring parsed as a JavaScriptDatewill implicitly apply the browser’s local timezone, which may lead to unintended time shifts.
- A
Possible backend improvements
- Standardize and document the serialization format
- Define the exact formats the API guarantees:
NaiveDate:YYYY-MM-DDNaiveDateTime:YYYY-MM-DDTHH:mm:ss(.SSS)(noZ, no offset)
- Ensure all endpoints follow the same convention, including the policy for fractional seconds.
- Define the exact formats the API guarantees: