4 July 2023

Advantages of writing your own date & time wrapper classes

Common wisdom holds that you shouldn’t roll your own when it comes to date & time handling. For the intricacies of leapyears and timezone calculations this is of course true, but I’ve had good experiences in the past with in-house wrapper classes for the entities themselves. You can use whatever date library you want under the hood – including native Date – and only expose what you need, in the way that best suits your needs.

Although we can all agree on roughly what a “date” or a “time” is when talking about them, when it comes to actually writing code, each application will have a slightly different way of using the concept. Dates and times are very likely to be entities with a specific meaning in relation to your business logic, and the interfaces of generic libraries often won’t align with this meaning.

Size

Wrapper classes can be small and easy to understand because you can eliminate all the generic functionality offered by most date libraries that you’re not actually using.

Predictability & consistency

You can enforce consistency by only including one way of parsing and formatting dates. Integrate this into your toJSON and reviver functions and you never have to think about it again.

If the date library you’re using underneath is mutable, you can make yours immutable by doing any necessary cloning in the wrapper.

Separate Date, Time, and DateTime classes

Libraries often use “date” to mean a date and a time, but the use-cases for those things don’t always perfectly align in the code. Sometimes you’re talking about a day, in which case the time part is meaningless and potentially confusing. Other times you want the date and time, and perhaps more rarely just the time.

If you roll your own you can be as precise as you need to be. I find code much easier to reason about when Date and DateTime are clearly distinguished, requiring explicit conversion in order to combine or compare them.

Easy escape hatch

When you do need to pass a native Date or some other interface around, your classes can include a toJsDate() or similar.

Example

let moment = require("moment");
let enGb = require("moment/locale/en-gb");

moment.locale("en-gb");

/*
wrapper over moment with clearer, immutable api
*/

let formats = {
	date: "YYYY-MM-DD",
	datetime: "YYYY-MM-DD HH:mm:ss",
};

let typeFromLength = {
	[formats.date.length]: "date",
	[formats.datetime.length]: "datetime",
};

class DateTime {
	constructor(type, m) {
		this.type = type;
		this.moment = m;
		this._format = formats[type];
		this.string = this.moment.format(this._format);
		this.isDate = type === "date";
		this.isDateTime = !this.isDate;
		this.time = this.string.split(" ")[1] || null;
	}
	
	isAfter(dateTime) {
		return this.moment.isAfter(dateTime.moment);
	}
	
	isBefore(dateTime) {
		return this.moment.isBefore(dateTime.moment);
	}
	
	equals(dateTime) {
		return this.moment.isSame(dateTime.moment);
	}
	
	add(...args) {
		return new DateTime(this.type, this.moment.clone().add(...args));
	}
	
	subtract(...args) {
		return new DateTime(this.type, this.moment.clone().subtract(...args));
	}
	
	diff(dateTime, ...args) {
		return this.moment.diff(dateTime.moment, ...args);
	}
	
	fromNow() {
		return this.moment.fromNow();
	}
	
	from(dateTime) {
		return this.moment.from(dateTime.moment);
	}
	
	date() {
		return DateTime.date(this.moment.clone());
	}
	
	dateTime() {
		return new DateTime("datetime", this.moment.clone().startOf("day"));
	}
	
	at(time) {
		let date = this.string.split(" ")[0];
		
		return DateTime.fromString(date + " " + time);
	}
	
	format(...args) {
		return this.moment.format(...args);
	}
	
	toString() {
		return this.moment.format(this._format);
	}
	
	toJSON() {
		return {
			_type: "DateTime",
			value: this.string,
		};
	}
	
	static fromJson(json) {
		return DateTime.fromString(json);
	}
	
	static fromString(string) {
		let type = typeFromLength[string.length];
		let format = formats[type];
		
		let m = moment(string, format);
		
		if (!m.isValid()) {
			throw "Invalid date "" + string + """;
		}
		
		return new DateTime(type, m);
	}
	
	static now() {
		return new DateTime("datetime", moment());
	}
	
	static today() {
		return new DateTime("date", moment().startOf("day"));
	}
	
	static dateTime(string) {
		return new DateTime("datetime", moment(string));
	}
	
	static date(string) {
		return new DateTime("date", moment(string));
	}
	
	static fromDb(type, string) {
		return new DateTime(type, moment(string));
	}
	
	static isDateTime(value) {
		return value instanceof DateTime;
	}
	
	static compare(a, b) {
		if (a.isAfter(b)) {
			return 1;
		} else if (a.isBefore(b)) {
			return -1;
		} else {
			return 0;
		}
	}
}

module.exports = DateTime;