Model time units as classes with operator support (+, >, .sum)

I am modeling units of time through case classes. OOP Hierarchy

sealed abstract class Time
├── case class Second(Double)
└── case class Minute(Double)

I have a few requirements:

1) Arithmetic operations must always return the type of the left-hand-side operand; a Second + T is always a Second, and vice versa.

val a = Second(5)
val b = Minute(3)

a + b // Second(185.0)
b + a // Minute(3.0833)

1.1) This should also apply to scenarios when an operand’s type is only known vaguely as Time. A Time + T will evaluate as a vague Time type also, though the runtime value
will be a specific concrete subclass of course. While a T + Time will evaluate as a T specificially.

val general: Time = Second(10) 
val specific = Second(5)
general + specific // Second(15): Time
specific + general // Second(15): Second

2) Comparison operations must work for all permutations of types

val a = Second(5)
val b = Minute(5)
a < b // true
b > a // true

2.1) including scenarios of vague Time operands

val general: Time = Second(5)
val specific = Minute(10)
general < specific // true
specific > general // true
general <= general // true

3) Standard library methods like .sum and .max must work for sequences

The known type will evaluate as the T in Seq[T], and the runtime value type will be the type of the first element.

Seq(Second(5), Second(3)).sum // Second(8): Second
Seq(Second(5), Minute(3)).sum // Second(185.0): Second | Minute
Seq(Minute(5), Second(3)).sum // Minute(3.0833): Second | Minute
Seq[Time](Second(5), Second(3)).sum // Second(8): Time

Seq(Second(5), Minute(1)).max // Minute(1): Second | Minute

I would prefer not having to implement these methods myself if possible. Is there a more automatic technique to get stdlib methods willing to operate on my own quantity types?

I also plan to support more types, Hour, Millisecond, Microsecond and so on, with operations between all of them supported. So, generalized and DRY solutions are important to avoid an unfeasibly large number of permutations.

Thank you.

Scastie: Scastie - An interactive playground for Scala.
(But you may diverge if you think there are ways my current implementation can be improved)

You need to define a given Numeric if you want to gain number-like functionality automatically (it’s in scala.math.Numeric). The LHS dominance of + is up to you; you will have to override the operations.

1 Like

There’s a lot to unpack here, but some initial thoughts:

I suspect you’re going to find that you want an underlying common unit of conversion in order to make this work. In practice, time libraries pretty much always reduce to seconds, milliseconds or nanoseconds under the hood, which makes a lot of these problems easier to manage. I would bet that you’ll be happier if you include something like that, rather than trying to keep the subtypes completely separate.

My instinct is that that implies you don’t want to implement + as an OO method in that case, but as a typeclass method instead – it’s generally easier to define a method that returns T that way. (I suspect you’ll find you also need a method to instantiate your unit types – that the + implementation reduces to the underlying unit, does the addtion, and then creates and returns an instance of the target unit.)

If you want this to work with the OOTB standard library methods like .sum, you’ll need to implement the Numeric type class.

(I personally prefer to use the Cats library for this sort of thing – for example, the Monoid type class there is basically the higher-level abstraction of “addition”, and tends to be easier to work with, but it requires using different functions than the standard library ones – not sure if that’s feasible for your requirements or not.)

But I should note that I’m not sure it’s possible to satisfy your requirement about the resulting type there – I’m reasonably certain that mixed sequences will wind up as Time using the standard library methods, and it’s an interesting little challenge to even come up with signatures that do that, even if it’s completely custom.

2 Likes

Are you sure you need to retain the specific types? Like, what is the advantage of preserving seconds or minutes?
If anything, it feels that it would be less useful, time is time.

Personally, I would just implement Numeric for the general Time type.
Moreover, I don’t think exposing stuff like Second or Minute as types / cases is useful at all.

Previously I represented the units as opaque types for Double, and had all operations reduce to milliseconds. But I’ve since felt that it’s slightly more ergonomic to just keep the numbers wrapped in case classes; the overhead seems negligible and I have more control - and, probably more importantly, it sets precedent for the way I’ll do things in the future; case classes seem better than opaque types most of the time.

What do you think? It’s not clear to me why I have to pick one singular base unit. If I am interfacing with more primitive API that operates on raw numbers to represent their unit ( like milliseconds) I would just use my case classes at the call site api_call(x=Millisecond(5).number)

That’s not quite what I’m saying. I think it totally makes sense that the API is in terms of distinct units, at least most of the time. But internally I suspect you’re going to find it much better if, eg, there’s a common toMillis method or some such, so that the math and comparisons can all be done in a consistent way.

1 Like

What about something like:

case class Time(ms: Double):
 // All the methods you want, for example
 val preferredUnit = ...
def toString = ... // where you use preferredUnit to chose how to display

object Time:
  def second(s: Double) = Time(s * 1000)
  def minute(m: Double) = second(m * 60)
  ...
  // You can even cheat !
  def Second(s: Double) = Time(s * 1000)

You have uniformity and control !

1 Like

Seconds and Minutes are different types of things. That is a simple truth I think is important to express in code, for ensuring comprehension and avoiding bugs. I think all units (time, distance, mass, rotations, temperature etc.) should be represented as distinct types nominally.

I like this excerpt:

The mars climate orbiter crashed into mars. The reason it crashed was quite interesting for me as a type theorist; ground-base software was working in imperial units, and the orbiter itself was working in metric units. In my world this is a type error. In any reasonable world this is a type error. But in a mainstream ordinary programming language this isn’t a type error, because they’re both “integers” or “floating-point numbers” or whatever.
– Edwin Brady “Scala vs Idris: Dependent types, now and in the future”, 2013

What you find if you don’t do this, and instead use raw numbers as the more traditional lower-level communities using older languages do, is that:

1) readability worsens - you must the write the unit in the parameter name or resort to comments

def f(elapsed: Float) // too vague, unclear what unit

def f(elapsedMilliseconds: Float) // compensate through verbose name

So you still have a typing / reading overhead, but worse.

2) an entire world of bugs now become possible that otherwise would not be.

val seconds = 5
f(seconds) // user wants to pass in 5 seconds, actually 5 milliseconds.
// the compiler will let this happen without alerting.

3) You feel forced to reason about everything in terms of a single unit (usually milliseconds), even if it is much easier to speak in terms of less granular units like hours or days.

if time.elapsed >= 86400000 // this is terrible to look at
if time.elapsed >= 10.days // much better

Raw numbers for units has caused me personally lots of pain while working on things like games. I would like all codebases to stop doing it, and I wager this unit semantic modeling pattern is eventually going to become the norm – excluding very low-level contexts where it’s too expensive of course. Ideally, languages could ship with a stdlib containing all the common scientific units by default so users don’t need to craft this by hand.

def f(elapsed: Time)

Seems better to me than

def f(elapsed: Second)

(regardless of how Time is implemented)

error: Found: Int, Expected: Time
(again regardless of how Time is implemented, I don’t think anyone is arguing against there being a use for a distinct type for time units)

Continuing my example:

extension (x: Int)
  def days = Time.day(x)

Having a unified representation in the backend does not forbid you from having a nice API
I would even argue having it simple in the backend allows you to make it prettier in the frontend

3 Likes

Perhaps I misread @BalmungSan’s reply; I did think he was arguing for avoiding these wrapper types entirely in favor of raw doubles when I read his last sentence because of the “Morever,” apologies if I misinterpreted. I can see an interpretation where he only meant the subclasses.

Also you might be really interested in pre-existing libraries !

See for example squants (which I just found out about):

scala> val ratio = Days(1) / Hours(3)
ratio: Double = 8.0
scala> val load = Kilowatts(1.2)
load: squants.energy.Power = 1.2 kW

scala> val time = Hours(2)
time: squants.time.Time = 2.0 h

scala> val energyUsed = load * time
energyUsed: squants.energy.Energy = 2400.0 Wh
4 Likes

I think he meant Second and Minute as opposed to Time

What method would you make where you require the person pass a Minute, and not just a Time, since it’s all type-safe anyways ?

1 Like

D’oh – right! I was sure there was a Typelevel solution for this, went and looked at Spire and confused myself. Good point!

(Although now that I look more closely, that needs some TLC. Hmm…)

1 Like

One past example I was modelling chemical elements, and I wanted to enforce definitions of their properties to be consistent with their canonical entries in science, so a (g/cm^3) ratio, and I think I did something like this?

type UnitRatio[A, B] = (A, B)
// can extend operations on this unit ratio also
// to do math on ratios.

abstract class Element(
  val density: UnitRatio[Gram, Centimeter3]
)
class Gold extends Element(
  density = (19.32.grams -> 1.cm3)
)
// not sure if this way of representing ratios and ^3 makes sense, was a while ago.
// but hopefully you get the idea.  

With the idea that I wanted to prevent users from defining new elements with other weird mass/size ratio, like kg to cm3 or lbs to cm3, it could lead to confusing scenarios and a less consistent codebase. So restricting the argument to only Gram and Centimeter3 subtypes seemed slightly advantageous.

Analogously I could see codebases where I want to enforce the description of velocity to be (Mile -> Hour) “mph”, not allowing metric units in that section of the codebase. So, you would need distinct Hour and Mile subtypes for that.

I see what you mean, but I’m not sure I am convinced, or rather not convinced that this is a typing issue, and not possibly a code review issue

Yes, to clarify I mean exactly what @Sporarum said.
I totally agree using raw numbers is error prone, but I don’t think that using specific units as types helps to reduce errors. Which is why I said: “time is time”.

I was imagining exactly this code:

I wouldn’t see any use case for having a method accept only Seconds, it may even be tedious for callers to do something like:

f(elapsed = Time.Minutes(5).toSeconds)

Note, however, this advice is solely limited to the specific example of Time with Minutes and Seconds.
If you change the domain, the modelling may change.

But the main idea is, don’t add types because they feel real.
Add types driven by real safety necessities.

There may be more strong theoretical arguments for it I’ll think of later, but an immediate pragmatic angle is that making it impossible to even write code that would warrant a code-review sounds pretty nice. One less thing to worry about.

Sadly in my experience this tends to just move the jank around, and so code review remains useful, see:

Which I would be against at code review, but then what’s the alternative?

f(elapsed = Time.Seconds(5 * 60))

f(elapsed = Time.Seconds(300))

That’s exactly what you didn’t want !

Anyways, I don’t want to dissuade you from writing your library, I hope you have fun with it !

1 Like

Yeah I agree this would be awkward, you almost always want the callers to use whichever units they find most comfortable and not be forced to use Second – and to be clear, I have the vast majority of function parameters typed in my codebase abstractly as Time and not as more specific subtypes, to give that flexibility to callsites.

I’ve only gone with the subtype hierarchy approach because I wanted to keep this ability of forcing specific units open as a possibility in future edge-cases, where it may be beneficial for me to be a dictator on what language callers get to use. But this does introduce this opposite risk of those dictators who create the APIs typing their function’s more specifically than they reasonably should, you might say x: Second out of habit when you meant to say x: Time, worsening callsites… We’ll see how it goes in practice.

Anyways, I don’t want to dissuade you from writing your library, I hope you have fun with it !

Thanks!

2 Likes

There’s also Typelevel Coulomb, for Scala 3, which I personally find sets the “state of the art” for unit systems in Scala and is actively developed.

2 Likes