Dates formats and timezones have for many years and probably for many more to come, been the nightmare of many developers and also the root of a lot of bugs in very different systems.
Dates in TypeScript
By typing a variable as Date
in TypeScript you are defining that:
1- it is an object
2- it represents a Date
3- it has access to a lot of date-related methods- e.g. getDate
However, there are still two very important pieces of information that aren't defined in any way by the type system:
1 - What's the date's timezone?
2 - What's the date's format?
Narrowing dates information
Unfortunately, for the timezone, there isn't much that TypeScript can do for us, because it would be "impossible" for a static analysis tool to understand from a given "date string", which timezone it represents, at least without any further info or complex runtime validation.
However, for a date's format, TypeScript can be very helpful and provide just enough narrowing for us to validate if a "date string" represents a given format, all within the type system! Here is a brief example of how we could define a type for the European date string format:
// European DD/MM/YYYY
type EuropeanDate = `${number}${number}/${number}${number}/${number}${number}${number}${number}`;
const europeanDate: EuropeanDate = "20/10/2024"; β
Eventually, this may lead to some false positives, like the "MM/DD/YYYY" (American format), but type-wise, those formats are the same, so there isn't much we can do there.
Dates in Data Contracts
A data contract outlines how data can get exchanged between two parties. atlan
Data contracts are in it's essence, a way to communicate what data a given resource is going to deliver and expects to receive.
In the TypeScript ecosystem, having these contracts well-defined (or even auto-generated) is a must, as a lot of times clients rely on them to make requests and manipulate/present the response data.
But date
types are usually a bit different from the rest of the data, due to their sensibility, they usually need to be passed around in the same format, but how can we know in which format, if the contract is defined like this:
type CreateTask = {
name: string;
// date in ISO format (is it?)
dueDate: string;
}
type GetTask = {
name: string;
// date in ISO format
dueDate: string;
}
As you can see above, it's very common that we add comments pointing out what's the actual date format, due to the absence of a better alternative.
Enforcing a date format
The most common date format used in IT systems is probably the ISO 8601
, it is a standard date and time representation that ensures consistency and is easily sortable, also, it has very good support across different programming languages and systems.
- Basic format:
YYYY-MM-DD
- Extended format:
YYYY-MM-DDTHH:MM:SS
This means that ideally, our data contracts would also have all the date fields typed as ISO
somehow.
type GetTask = {
name: string;
// date in the ISO format!
dueDate: TDateISO;
}
Defining the TDateISO
type
The 1st step to have our date field typed properly, is obviously to define a TypeScript type
that represents the ISO 8601
date string format.
This type was extracted from this gist.
type TYear = `${number}${number}${number}${number}`;
type TMonth = `${number}${number}`;
type TDay = `${number}${number}`;
type THours = `${number}${number}`;
type TMinutes = `${number}${number}`;
type TSeconds = `${number}${number}`;
type TMilliseconds = `${number}${number}${number}`;
/**
* Represent a string like `2021-01-08`
*/
type TDateISODate = `${TYear}-${TMonth}-${TDay}`;
/**
* Represent a string like `14:42:34.678`
*/
type TDateISOTime = `${THours}:${TMinutes}:${TSeconds}.${TMilliseconds}`;
/**
* Represent a string like `2021-01-08T14:42:34.678Z` (format: ISO 8601).
*
* It is not possible to type more precisely (list every possible values for months, hours etc) as
* it would result in a warning from TypeScript:
* "Expression produces a union type that is too complex to represent. ts(2590)
*/
type TDateISO = `${TDateISODate}T${TDateISOTime}Z`;
As stated in the comments in the code block, this isn't bulletproof, but as of right now, it is as far as we can go, type-wise, and it's probably good enough for a data contract where you control both the frontend and the backend of the systems communication.
Overriding native toISOString
As you can see above, the native JS Date
prototype toISOString()
isn't ready for this new type that we just created and in fact, it just returns a simple string
. So, we must somehow override its return type.
Luckily for us, the Date
type is defined in an interface
, and interfaces can be "merged" with other interfaces due to declaration merging - meaning that we can create a new interface with the same name, and modify the existing types (within certain limits).
// add this to any TS module (file with exports/imports)
interface Date {
/**
* Give a more precise return type to the method `toISOString()`:
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString
*/
toISOString(): TDateISO;
}
Once that's done, anytime we use the native toISOString
, will result in the TDateISO
that we created in the previous step. π
Summary
1- Standardize all the data contract's dates' format
2- Setup a new TS type that can represent that date format
3- Change the dates in the contracts to the new type
4- Narrow the return type of the native Date functions, if it applies to your system's date format
Conclusion
Dates don't have an easy time in the TypeScript ecosystem, due to so many different variations in formats and timezones, it is still pretty much impossible to have full type safety.
However, for date formats, it is already possible to narrow down the types enough, to get a much better and safer Developer Experience.
Make sure to follow me on twitter if you want to read about TypeScript best practices or just web development in general!
Top comments (1)
Great trick! It's a new one for me.
I was testing here and want to add that it only requires a minimum amount of numbers, but not a maximum.
In my tests, it's still valid:
In any case, your solution is better than the vanilla TS, thanks!