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!
The post Handling date strings in TypeScript appeared first on LogRocket Blog.
from LogRocket Blog https://ift.tt/ODewJT8
via Read more