Alain Perkaz A passionate and disciplined software engineer.

Handling date strings in TypeScript

3 min read 1075

Typescript Date Strings

In one of my recent projects, I had to deal with multiple custom representations of dates as strings, like YYYY-MM-DD and YYYYMMDD. Since those dates are string variables, TypeScript infers the string type by default. While this isn’t technically wrong, working with such a type definition is broad, making it tough to work effectively with those date strings. For example, const dog = 'alfie' is also inferred as a string type.

In this article, I’ll take you through my approach for improving the developer experience and reducing potential bugs by typing those date strings. You can follow along with this Gist. Let’s get started!

Table of contents

Before we jump into the code, let’s briefly review the TypeScript features we’ll leverage to achieve our goal, template literal types and narrowing through type predicates.

Template literal types

Introduced in TypeScript v4.1, template literal types share the syntax with JavaScript template literals but are used as types. The type template literals resolve to a union of all the string combinations for a given template. This may sound a little abstract, so let’s see it in action:

type Person = 'Jeff' | 'Maria'
type Greeting = `hi ${Person}!` //   Template literal type

const validGreeting: Greeting = `hi Jeff!` // 
//  note that the type of `validGreeting` is the union `"hi Jeff!" | "hi Maria!`
const invalidGreeting: Greeting = `bye Jeff!` // 
// Type '"bye Jeff!"' is not assignable to type '"hi Jeff!" | "hi Maria!"

Template literal types are very powerful, allowing you to perform generic type operations over those types. For example, capitalization:

type Person = 'Jeff' | 'Maria'
type Greeting = `hi ${Person}!`
type LoudGreeting = Uppercase<Greeting> // Capitalization of template literal type

const validGreeting: LoudGreeting = `HI JEFF!` // 
const invalidGreeting: LoudGreeting = `hi jeff!` // 
// Type '"hi Jeff!"' is not assignable to type '"HI JEFF!" | "HI MARIA!"

Type predicate narrowing

TypeScript does a phenomenal job at narrowing types, for example, in the following example:

let age: string | number = getAge();

// `age` is of type `string` | `number`
if (typeof age === 'number') {
  // `age` is narrowed to type `number`
} else {
  // `age` is narrowed to type `string`
}

That said, when dealing with custom types, it can be helpful to tell the TypeScript compiler how to perform the narrowing, for example, when we want to narrow to a type after performing a runtime validation. In this case, type predicate narrowing, or user-defined type guards, comes in handy!

In the following example, the isDog type guard helps narrow down the types for the animal variable by checking the type property:

type Dog = { type: 'dog' };
type Horse = { type: 'horse' };

//  custom type guard, `pet is Dog` is the type predicate
function isDog(pet: Dog | Horse): pet is Dog {
  return pet.type === 'dog';
}

let animal: Dog | Horse = getAnimal();
// `animal` is of type `Dog` | `Horse`
if (isDog(animal)) {
  // `animal` is narrowed to type `Dog`
} else {
  // `animal` is narrowed to type `Horse`
}

Typing the date strings

Now that we are familiar with the building blocks of TypeScript, let’s make our date strings bulletproof. For the sake of brevity, this example will only contain the code for YYYYMMDD date strings. All of the code is available in the following Gist.

First, we’ll need to define the template literal types to represent the union of all the date-like stings:



type oneToNine = 1|2|3|4|5|6|7|8|9
type zeroToNine = 0|1|2|3|4|5|6|7|8|9
/**
 * Years
 */
type YYYY = `19${zeroToNine}${zeroToNine}` | `20${zeroToNine}${zeroToNine}`
/**
 * Months
 */
type MM = `0${oneToNine}` | `1${0|1|2}`
/**
 * Days
 */
type DD = `${0}${oneToNine}` | `${1|2}${zeroToNine}` | `3${0|1}`
/**
 * YYYYMMDD
 */
type RawDateString = `${YYYY}${MM}${DD}`;

const date: RawDateString = '19990223' // 
const dateInvalid: RawDateString = '19990231' //31st of February is not a valid date, but the template literal doesnt know!
const dateWrong: RawDateString = '19990299'//  Type error, 99 is not a valid day

As seen in the example above, the template literal types help specify the shape of date strings, but there is no actual validation for those dates. Therefore, the compiler flags 19990231 as a valid date, even if it is not, because it fulfills the type of the template.

Also, when inspecting the variables above like date, dateInvalid, and dateWrong, you’ll find that the editor displays the union of all valid strings for those template literals. While useful, I prefer setting nominal typing so that the type for valid date strings is DateString instead of "19000101" | "19000102" | "19000103" | .... The nominal type will also come in handy when adding user-defined type guards:

type Brand<K, T> = K & { __brand: T };
type DateString = Brand<RawDateString, 'DateString'>;

const aDate: DateString = '19990101'; // 
// Type 'string' is not assignable to type 'DateString'

To ensure that our DateString type also represents valid dates, we’ll set up a user-defined type guard to validate the dates and narrow the types:

/**
 * Use `moment`, `luxon` or other date library
 */
const isValidDate = (str: string): boolean => {
  // ...
};

//User-defined type guard
function isValidDateString(str: string): str is DateString {
  return str.match(/^\d{4}\d{2}\d{2}$/) !== null && isValidDate(str);
}

Now, let’s see the date string types in a couple of examples. In the code snippets below, the user-defined type guard is applied for type narrowing, allowing the TypeScript compiler to refine types to more specific types than declared. Then, the type guard is applied in a factory function to create a valid date string from an unsanitized input string:

/**
 *   Usage in type narrowing
 */

// valid string format, valid date
const date: string = '19990223';
if (isValidDateString(date)) {
  // evaluates to true, `date` is narrowed to type `DateString` 
}

//  valid string format, invalid date (February doenst have 31 days)
const dateWrong: string = '19990231';
if (isValidDateString(dateWrong)) {
  // evaluates to false, `dateWrong` is not a valid date, even if its shape is YYYYMMDD 
}


/**
 *   Usage in factory function
 */

function toDateString(str: RawDateString): DateString {
  if (isValidDateString(str)) return str;
  throw new Error(`Invalid date string: ${str}`);
}

//  valid string format, valid date
const date1 = toDateString('19990211');
// `date1`, is of type `DateString`

//  invalid string format
const date2 = toDateString('asdf');
//  Type error: Argument of type '"asdf"' is not assignable to parameter of type '"19000101" | ...

//  valid string format, invalid date (February doenst have 31 days)
const date3 = toDateString('19990231');
//  Throws Error: Invalid date string: 19990231

Conclusion

I hope this article shed some light on what TypeScript is capable of in the context of typing custom strings. Remember that this approach is also applicable to other custom strings, like custom user-ids, user-XXXX, and other date strings, like YYYY-MM-DD.

The possibilities are endless when combining user-defined type guards, template literal strings, and nominal typings. Be sure to leave a comment if you have any questions, and happy coding!

Writing a lot of TypeScript? Watch the recording of our recent TypeScript meetup to learn about writing more readable code.

TypeScript brings type safety to JavaScript. There can be a tension between type safety and readable code. Watch the recording for a deep dive on some new features of TypeScript 4.4.

Alain Perkaz A passionate and disciplined software engineer.

One Reply to “Handling date strings in TypeScript”

Leave a Reply