A proposal - If your crate provides or helps define basic data types/primitives, let *me* write the definition!
It becomes quite frustrating when you want to use a crate which lets you define or predefines some pretty plain primitives/structs/enums types to use across your codebase using macros - without letting you write the definition.
One example (maybe not the best one) is the UOM crate - which lets you define systems of units with compile-time dimensional analysis:
use uom::si::f32::Length;
use uom::si::length::kilometer;
let length = Length::new::<kilometer>(5.0);
And adding your own unit systems with custom units is done like:
system! {
quantities: Q {
length: meter, L;
}
units: U {
mod length::Length,
}
}
This is a quite nifty macro and it makes defining this quite succinct.. But take a step back and think about these kinds of basic-type-generating-macros....
- Documentation: How do I put a documentation comment in this macro? What if the macro doesn't support it?
- Language Ergonomics: What if the developer wrote the macro such that go-to-definition doesn't work?
- Derives: Most macros that generate types don't support deriving custom things, 99% just have a
serdefeature flag that globally enables the derive on all types without selectivity and no other derives you may want (e.g. schemars, validator, bevy reflection). - Backing Primitives The crate that provides the macros/types has to have a bunch of feature flags for every single data type that can back its "primitive-like" types. With UOM, there's like ~27 feature flags for this. When another crate makes a new "primitive-like" type, the problem happens again. Imagine we have a new "bigint" crate - now, every crate that has feature flags to enable generating types that have backing primitives has to add yet another feature flag.
- Type layout: The crate could change the memory layout of your little data type with an update.
- Derives - Field Attributes: Since you didn't write the definition - how would you add a deserialization alias with
serde? - Trait Implementation: You can't implement your own traits without a newtype, the ergonomics of which can get real bad.
- Trait Implementation part 2: Implementing your own trait on these macro-generated-primitives is not the best idea when it's against a type which may change on you - even when it's super obvious and a super basic type like a single u32.
These types are really for me and my codebase. They're really plain, their definition is obvious, it shouldn't be this hard to use them like any other type I may define!
What if I could write:
#[dimension]
#[from(Centimeter, 0.001)]
struct Length(u32);
struct Centimeter(u32);
And it'd impl From<Centimeter> for Length
Then, I can derive whatever I want without features flags, customize the types to be whatever I want, implement my own traits, add serde attributes! The definition of the type is fixed. I don't think we think about how convenient it is that we can just #[derive(Serialize)] and we don't have to make a newtype, and we still get control over how serialization happens. Imagine we had the alternative with serde, or clap...
make_serializable_type! {
foo: u32, alias: "bar", min: 5
}
Okay, that's rather succinct - but I've thrown control of my type, it's layout, derives, traits, everything to the library, it's no longer my type.
This is an N*M problem.
- N = the number of fancy traits we want (Serialize, Deserialize, JsonSchema, Validate).
- M = the number of fancy primitive types we want to implement features/traits for (bigint, all std primitives, rational, duration, chrono).
So, my proposal is basically 2 things...
1. Type-Generating Macros Considered Harmful.
Avoid type-generating macros at all costs. Let the user type their definition and EXTEND them with your crate. Preferably with simple traits and derives, like Clap/Serde.
2. Let Users Build Up Their Own Types
If your crate predefines a bunch of types for a user that are essentially primitives, consider instead giving them the tools to directly define the ones they need in bulk themselves.
For example - I'm not afraid to copy and paste the definition of a few key SI dimensions/units into my own code-base - it makes it easier to work with, its super simple to see how to add another one, it speeds up compilation, it lets me omit certain units, it lets me customize things...