If you’re already familiar with the pattern please skip to the next section.
The newtype pattern refers to wrapping a type in a (usually) single field tuple struct. For example:
struct UserId(pub u64);
Note that this is different than defining a type alias:
type UserId = u64;
Take the following example.
struct UserId(pub u64);
struct OrderId(pub u64);
fn delete_user(id: UserId) {
// ...
}
Passing a value of type OrderId into this function
results in a compile error. If UserId and
OrderId were just type aliases for u64
passing both would compile but with probably unintended
behavior.
The example will be from a drawing app that I was developing recently. More specifically we’ll be concerned about conversion between the screen and canvas coordinate systems. The canvas is the space where your drawings (lines, images, etc.) live; while the camera defines which part of the canvas is visible and can pan and zoom.
Say, for example, that a circle has been drawn at with a radius of 5. The camera has been moved to and the user applied a 2x zoom. The circle we should display on the screen should be located at with a radius of 10.
Let’s get some basic definitions in place.
struct Camera {
pub pos: Vector2,
pub zoom: f32,
}
struct Circle {
pub pos: Vector2,
pub r: f32,
}
Since we want to implement the conversion for
Vector2 and f32 types (position and
radius) and rust doesn’t allow function overloading let’s use a
trait.
trait ScreenCanvasCoords {
fn to_screen(self, camera: &Camera) -> Self;
fn to_canvas(self, camera: &Camera) -> Self;
}
impl ScreenCanvasCoords for Vector2 {
fn to_screen(self, camera: &Camera) -> Self {
(self - camera.pos) * camera.zoom
}
fn to_canvas(self, camera: &Camera) -> Self {
self / camera.zoom + camera.pos
}
}
impl ScreenCanvasCoords for f32 {
fn to_screen(self, camera: &Camera) -> Self {
self * camera.zoom
}
fn to_canvas(self, camera: &Camera) -> Self {
self / camera.zoom
}
}
Now the api will look like this:
draw(circle.pos.to_screen(&camera), circle.r.to_screen(&camera));
Done. This works well and the api seems ergonomic so what’s the problem?
Here are some of them:
Vector2 that got passed to this function?).And the problems get worse as the codebase grows.
The newtype pattern addresses all of them (and more).
For the low (?) price of a little boilerplate.
Let’s start with the position. We could create two separate structs like so:
struct ScreenPoint(Vector2);
struct CanvasPoint(Vector2);
But I like the generic parameter version better
(Point<ScreenSpace>,
Point<CanvasSpace>). For one we can now do a
generic impl block
(impl<Space> Point<Space> { ... }) to avoid
repeating the same methods. We can also easily make other structs
generic over the space if needed (for example
Circle<Space> if we need some circles to live in
the screen space and other on the canvas).
The name Space here refers to the coordinate
space if that wasn’t clear. I’ll be using it a lot.
struct ScreenSpace;
struct CanvasSpace;
struct Point<Space>(Vector2);
Aaaand…
error[E0392]: type parameter `Space` is never used
help: consider removing `Space`, referring to it in a field,
or using a marker such as `PhantomData`
It seems that unused generic parameters aren’t allowed. The error
helpfully suggests using PhantomData so let’s do just
that.
struct Point<Space> {
v: Vector2,
_marker: PhantomData<Space>,
}
This unfortunately means that you have to initialize the marker
when creating the struct. We need a new method (we
would need one anyway since I want to keep the underlying
Vector2 private).
You’ve been warned about the boilerplate but stick around and you’ll be convinced that it’s worth it.
impl<Space> Point<Space> {
pub fn new(v: Vector2) -> Self {
Self {
v,
_marker: PhantomData,
}
}
}
Let’s also slap some type aliases while we’re at it.
type ScreenPoint = Point<ScreenSpace>;
type CanvasPoint = Point<CanvasSpace>;
Recall the trait we defined earlier:
trait ScreenCanvasCoords {
fn to_screen(self, camera: &Camera) -> Self;
fn to_canvas(self, camera: &Camera) -> Self;
}
Notice that we no longer want to return Self after
the conversion. In fact we want to return the opposite coordinate
space from the one we were in. To achieve this we have to split the
trait into two and use an associated type like so:
trait ToScreen {
type Output;
fn to_screen(self, camera: &Camera) -> Self::Output;
}
trait ToCanvas {
type Output;
fn to_canvas(self, camera: &Camera) -> Self::Output;
}
Now we simply tell what type we’re returning when implementing the trait.
impl ToScreen for CanvasPoint {
type Output = ScreenPoint;
fn to_screen(self, camera: &Camera) -> Self::Output {
ScreenPoint::new((self.v - camera.pos) * camera.zoom)
}
}
impl ToCanvas for ScreenPoint {
type Output = CanvasPoint;
fn to_canvas(self, camera: &Camera) -> Self::Output {
CanvasPoint::new(self.v / camera.zoom + camera.pos)
}
}
Let’s stop for a moment and admire the creation. We can now know
the coordinate space of any given Point just by
checking its type, so that’s a lot of mental overhead gone.
We’re also unable to accidently call the wrong conversion method
(since ScreenPoint doesn’t implement
ToScreen and CanvasPoint doesn’t implement
ToCanvas). The idea that certain methods are only
available if an object is in a given “state” is very powerful.
Consider for example the builder
pattern. It can prevent the user from calling the same setter
twice or calling build before certain required fields
have been set.
Check out the typed-builder crate that implements a builder using this idea.
All seems nice while we’re in our little newtypes bubble. But the
truth is that we have to somehow interact with other apis. Remember
how earlier we had a Circle
struct that we wanted to draw on the screen. The draw
function is probably provided by some kind of external library and
the author of that library doesn’t care about our newtype
abstraction. Here are some path we could take.
The easy way is to just expose the underlying
Vector2 as public or add a getter. This is not bad but
I feel like it takes us away from the whole type safety
thing we had going on here. For example, we’d still have the problem
of forgetting to call to_screen before calling
draw.
We could write wrappers for those functions that accept our new types but that feels excessive and we’d still have to expose a getter to use the value inside those functions.
Since it only makes sense to draw something that is in the
screen coordinate space, we’ll only implement the
conversion method for the ScreenPoint type.
impl From<ScreenPoint> for Vector2 {
fn from(value: ScreenPoint) -> Self {
value.v
}
}
Implementing From gives you the corresponding
Into for free so you should pretty much always
implement From instead of Into (though
it’s not
that simple).
Bring up the draw call again.
draw(circle.pos.to_screen(&camera).into(), circle.r.to_screen(&camera).into());
Now we can interact with our drawing lib and are unable to pass a position in an incorrect coordinate space!
Notice how we’ve already addressed all the problems stated earlier. All that’s left is to add some more types.
I promise there is still some interesting stuff to cover…
Let’s introduce a new type for the Circle’s radius,
which is still just an innocent little f32 (I won’t be
showing the details anymore).
struct Length<Space> { /* ... */ }
And the conversion is just multiplying/dividing by
camera.zoom.
What’s nice is that now we can expose Point’s fields
without compromising the type safety.
impl<Space> Point<Space> {
fn x(&self) -> Length<Space> {
Length::new(self.v.x)
}
fn y(&self) -> Length<Space> {
Length::new(self.v.y)
}
}
Neat! But this is only useful if we’re actually able to do something with them, like, say, perform arithmetic.
We can use the same impl generic over Space
technique to ensure we don’t accidentally add a
ScreenLength to a CanvasLength.
impl<Space> ops::Add<Length<Space>> for Length<Space> {
type Output = Self;
fn add(self, rhs: Length<Space>) -> Self::Output {
Self::new(self.v + rhs.v)
}
}
The arithmetic operators’ implementations get more interesting if we add more types, so let’s do just that.
Consider the panning functionality of our drawing app.
impl Camera {
pub fn update_pos(&mut self, mouse_delta: ScreenPoint) {
self.pos -= mouse_delta.to_canvas(self);
}
}
The camera.pos was in CanvasSpace and
the mouse movement happened in the ScreenSpace. Our
types saved us from forgetting to convert the
mouse_delta to CanvasSpace coordinates!
Hold up… The conversion is wrong – we’re dividing by
camera.zoom and adding camera.pos – The
camera’s position should have nothing to do with the vector by which
the mouse moved through the canvas – just dividing by
camera.zoom is the correct conversion here. Which leads
us to the conclusion that we need another type (for a type we’ve
already wrapped in a newtype btw.).
Without newtypes the problem here would not only be “in which
coordinate space is this Vector2?” but also “does this
Vector2 represent a position or a displacement?”.
struct Vector<Space> { /* ... */ }
With conversions being just multiplying/dividing both components
of the Vector2 by camera.zoom. Thinking
about implementing arithmetic operators – the result of subtracting
two Points should be a Vector and you
should probably only be able to add/subtract a Vector
(not a Point) from a Point.
This is a nice example of how different types interact through arithmetic operators. Now for an example of interaction between types of different coordinate spaces.
Right now the camera.zoom field is still an
f32 (yuck). Make it a Length? But in which
coordinate space? Doesn’t fit in what we got so let’s cook up a new
type.
struct Scale<FromSpace, ToSpace> { /* ... */ }
type CanvasToScreenScale = Scale<CanvasSpace, ScreenSpace>;
That’s a long name for a type, but I think it describes it well.
Think about it – this is exactly what camera.zoom
is. It’s a scale from CanvasSpace to
ScreenSpace – multiplying a CanvasLength
by camera.zoom gets us a ScreenLength.
All the appropriate arithmetic operators’ and standard library’s traits’ implementations follow simply from all our previous considerations and we got ourselves a pretty convenient, robust api.
As I said at the beginning the example was taken from my drawing app. I extracted the camera part to a separate crate and ended up using euclid to save myself from having to implement all the common traits and operators.