3.1Exercise 3.1
Imagine you need to create an API for managing recurring schedules ("This event happens once per month"). A senior engineer argues that storing a value for seconds between events is sufficient for all the use cases. Another engineer thinks that the API should provide different fields for various time units (e.g., seconds, minutes, hours, days, weeks, months, years). Which design covers the correct meanings of the intended functionality and is the better choice?
Show solutionHide solution
This is an interesting question in that it brings up a debate between specificity (e.g., the number of seconds between two recurring events) and usability (in this case, the user wanting to say "this event occurs every week"). But there's actually a bit more to it.
Let's start by looking at some options of how we might define this interface. The first option is a single field representing an interval of seconds, defining how many seconds should elapse before the next instance of the event:
interface RecurringEvent {
name: string;
intervalSeconds: Number;
// ... more fields ...
}
Option 1: A single field defining the number of seconds between each recurring event.
The second option is to include additional units, such as minutes, hours, days, months, and years:
interface RecurringEvent {
name: string;
intervalSeconds: Number;
intervalMinutes: Number;
intervalMonths: Number;
}
Option 2: Several fields for each of the different recurrence interval units.
There's also a third option that relies on some sort of abbreviated syntax stored in a query-like string:
interface RecurringEvent {
name: string;
// s: seconds
// m: minutes
// mo: months
interval: string; // E.g., "1s 2m 3mo"
}
Option 3: A query-like string to represent the recurrence interval.
So which of these is "the best"? Let's start by looking at the first option against a possible scenario: a monthly recurrence.
If we want something to recur on the first of each month, we actually run into quite a problem with the first solution. The issue is pretty simple: a single month doesn't have a fixed number of seconds. Some months (e.g., February) have 28 days (2,419,200 seconds) whereas others might have 31 days (2,678,400 seconds), so right off the bat, this API definition doesn't allow us to do something as simple as a recurrence on "the first of every month", which is a big problem.
When it comes to Option 2 and Option 3, we have more flexibility. For this specific example, it's pretty simple: either intervalMonths: 1 or interval: "1mo" and our problem is solved.
This might leave one big question unanswered: Why don't we have hours, days, weeks, years, or other time units involved? Why have minutes when that can just be 60 seconds? The answer is simple: we need a unit for every case where the lesser unit is inconsistent. For example, we need months because each month varies in the lesser units (seconds, minutes, or days). On the other hand, we don't need years because while each year might have a variable number of days (after all, leap years have an extra day), a year is always exactly 12 months. Finally we need minutes because there just so happens to be such a thing as a "leap second", where we add an extra second to one of the minutes at the end of the month to account for variations in the earth's rotation. In other words, not every minute is 60 seconds (some are 61 seconds)!
If I had to pick a standard, I'd choose Option 3, however I would add in aliases for common scenarios to avoid forcing users to do additional math. For example, rather than saying "If you want to define 1 week just say "10080m" to be 10,080 minutes!" I'd include an alias (1w) to handle that same scenario. It would be the same as "However many minutes specified + 10,080 more". The same goes for days, hours, and years.
interface RecurringEvent {
name: string;
// s: seconds
// m: minutes
// h: hours (equivalent to +60m)
// d: days (equivalent to +1440m)
// w: weeks (equivalent to +10080m)
// mo: months
// y: years (equivalent to +12mo)
interval: string; // E.g., "1s 2m 3h 4d 5w 6m 7y"
}
And to interact with this, we might have some parsing code that looks like this:
interface Interval {
seconds: number;
minutes: number;
months: number;
}
function parseIntervalString(intervalString: string): Interval {
const interval: Interval = { seconds: 0, minutes: 0, months: 0};
const rawInterval: Map<String, number> = new Map();
const components = intervalString.trim().split(/ +/);
for (let component of components) {
let match = component.match(/^([0-9]+)(s|m|h|d|w|mo|y)$/);
if (match == null) throw new Error(`Invalid interval ("${component}").`)
let unit = match[2], value = parseInt(match[1]);
if (rawInterval.has(match[2])) throw new Error(`Invalid interval. Duplicate unit "${unit}".`)
rawInterval.set(unit, value);
if (unit == 's') interval.seconds += value;
else if (unit == 'm') interval.minutes += value;
else if (unit == 'h') interval.minutes += 60*value;
else if (unit == 'd') interval.minutes += 60*24*value;
else if (unit == 'w') interval.minutes += 60*24*7*value;
else if (unit == 'mo') interval.months += value;
else if (unit == 'y') interval.months += 12*value;
}
return interval;
}
