【译】在TypeScript中处理日期字符串

3,110 阅读4分钟

正文

在我最近的一个项目中,我必须去处理多个自定义的日期字符串表示法,比如YYYY-MM-DDYYYYMMDD。由于这些日期是字符串变量,TypeScript默认会推断成为string类型。虽然这在技术实现上没有错,但是在工作中使用这样的类型定义是很宽泛的,使得有效处理这些日期字符串变得很困难。例如,let dog = 'alfie'也被推断为一个string类型。

在这篇文章中,我会将我的解决方法呈现给你,通过输入这些日期字符串来改善开发者的体验并减少潜在的错误。

在进入编码之前,让我们简单回顾下实现目标需求要利用到的typescript特性,即模板字面量类型和通过类型谓词缩小范围。

模板字面量类型

在typescript4.1版本中引入,模板字面量类型和JavaScript的模板字符串语法相同,但是是作为类型使用。模板字面量类型解析为一个给定模板的所有字符串组合的联合。这听起来可能有点抽象,所以直接看代码:

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!"

模板字面量类型非常强大,允许你对这些类型进行通用类型操作。例如,大写字母化。

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!"

类型谓词缩小范围

typescript在缩小类型范围方面表现得非常好,可以看下面这个例子:

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`
}

也就是说,在处理自定义类型时,告诉typescript编译器如何进行类型缩小是有帮助的。例如,当我们想在执行运行时验证后缩小到一个类型时,在这种情况下,类型谓词窄化,或者用户定义的类型守护,就可以派上用场。

在下面这个例子中,isDog类型守护通过检查类型属性来帮助缩小animal变量的类型:

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`
}

定义日期字符串

为了简洁起见,这个例子只包含YYYYMMDD日期字符串的代码。

首先,我们需要定义模板字面量类型来表示所有类似日期的字符串的联合类型

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

从上面的例子可以得知,模板字面量类型有助于指定日期字符串的格式,但是没有对这些日期进行实际验证。因此,编译器将19990231标记为一个有效的日期,即使它是不正确的,只因为它符合模板的类型。

另外,当检查上面的变量如datedateInvaliddateWrong时,你会发现编辑器会显示这些模板字面的所有有效字符的联合。虽然很有用,但是我更喜欢设置名义类型,使得有效的日期字符串的类型是DateString,而不是"19000101" | "19000102" | "19000103" | ...。在添加用户定义的类型保护时,名义类型也会派上用场。

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

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

为了确保我们的DateString类型也代表有效的日期,我们将设置一个用户定义的类型保护来验证日期和缩小类型

/**
 * 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);
}

现在,让我们看看几个例子中的日期字符串类型。在下面的代码片段中,用户定义的类型保护被应用于类型缩小,允许TypeScript编译器将类型细化为比声明的更具体的类型。然后,在一个工厂函数中应用了类型保护,以从一个未标准化的输入字符串中创建一个有效的日期字符串。

/**
 *   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

总结

我希望这篇文章能让我们了解TypeScript在输入自定义字符串方面的能力。请记住,这种方法也适用于其他自定义字符串,如自定义user-idsuser-xxxx,以及其他日期字符串,像YYYY-MM-DD

当结合用户定义的类型防护、模板字面字符串和名义类型时,可能性是无穷的。如果你有任何问题,请务必留下评论,并祝你编码愉快!

参考