ADR-2: Sharing functionality across entities
This decision record describes how Typesaurus enables sharing functionality across the refs and docs.
Background
The most basic way to add functionality to a Typesaurus entity is to accept it as an argument, i.e.:
This approach is simple and works well for small projects. However, a big database sharing the same functionality across multiple entities would require a lot of code duplication, as trying to work with entities of different shapes will result in a type error:
Even though the collections extend the same interface, for TypeScript, the update
signatures aren’t compatible with each other:
While it’s possible to use generics to accept a shared shape instead (NameFields
in our example), it opens the door to data inconsistencies as the entities don’t know about the actual shape:
Goals
The goal is to allow extending the entities with shared functionality without code duplication and data inconsistencies.
The solution must preserve the full type safety and be tree-shakable so the unused functionality doesn’t end up in the production bundle.
Finally, the solution should be easy to use and understand.
Considered options
Mixing into schema
The most straightforward way to add functionality to the entities would be to mix in the functionality while defining the collections:
So when you get a document or a reference from the collection, it would have the mixed-in functionality:
Then, the $.collection
method will check the compatibility of the mixins and the collection’s schema, thus providing the type safety.
The data inconsistency won’t be an issue without narrowing down the type.
However, with this approach, the mixing-in happens at the schema level, pulling all the necessary functionality into the production bundle, even if it’s not used. Furthermore, the server code might leak into the client code, opening a security hole.
A solution to this problem could be separate schema definition and mixing-in by creating enriched versions of the database instance separately for the client and server. However, three database versions will make code more complex and reduce reusability, forcing developers to write the server and client code independently and defeating the purpose.
Stripping the type
Before v10, Typesaurus entities used to be simple objects without any methods and, hence, a smaller type surface. Because of that, it was easier to ensure the compatibility of the shared functions:
Without rewriting the whole library back to this architecture (and creating many other problems), a way to approach this would be to create a set of methods emulating this behavior:
With the strip
method and set of functions like update
that now accept the stripped entities, it would be possible to share the functionality across the entities without breaking the types.
Data inconsistency would be impossible without the add
, set
, and upset
methods.
However, this approach would introduce an additional API style, increasing the API surface and adding complexity and friction by forcing developers to choose between the two styles.
The solution
The solution to the problem that addresses all the goals is to provide a function that resolves a narrowed version of the entity as long as the passed shape is compatible with the entity’s shape:
If the entity’s shape is incompatible with the passed shape, the as
method will resolve unknown
, triggering a type error:
The methods that cause data inconsistencies don’t exist on the narrowed entity solving the problem: