【译】10个帮助你捕获更多Bug的TypeScript建议

 约 21 分钟
本文翻译自Miłosz Piechocki提供的TypeScript迷你书https://typescriptmasterclass.com(需要发送邮件获取)

其个人博客https://codewithstyle.info也有许多关于TS的文章可以学习。

1. 对TypeScript提供运行时检查的思考

有一个对TypeScript常见的误解是:一个变量只要标注了类型,那么它总是会检查自己的数据类型是否与我们的预期一致。

与该误解相呼应的想法会认为:对一个从后端返回的对象进行类型标注可以在代码运行时执行检查来确保对象类型的正确性。

然而这个想法是错误的!因为TypeScript最终是被编译成JavaScript代码,并且浏览器中运行的也是JavaScript。此时(译者注:运行时)所有的类型信息都丢失了,所以TypeScript无法自动验证类型。

理解这一点的一个好方法是查看编译后的代码:

interface Person {
  name: string;
  age: number;
}

function fetchFromBackend(): Promise<Person> {
  return fetch('http://example.com')
      .then((res) => res.json())
}

// 编译后
function fetchFromBackend() {
  return fetch('http://example.com')
      .then(function(res) {
        return res.json();
      })
}

可以看到接口定义在编译后已经完全消失了,而且这里也不会有任何验证性的代码。

不过你最好可以自己去执行运行时校验,许多库(译者注:io-ts)能帮你做到这点。不过,请记住,这一定会带来性能开销。

* 考虑对所有外部提供的对象执行运行时检查(例如从后端获取的对象,JSON反序列化的对象等)

2. 不要将类型定义为any

使用TypeScript时,可以将变量或函数参数的类型声明为any,但是这样做也意味着该变量脱离了类型安全保障。

不过声明为any类型也会有好处,在某种场景下很有帮助(例如将类型逐步添加到现有的JavaScript代码库中,译者注:一般是将代码库从js升级到ts时)。但是它也像一个逃生舱口,会大大降低代码的类型安全性。

当类型安全涵盖尽可能多的代码时,它是最有效的。否则,安全网中会存在漏洞,漏洞可能会通过漏洞传播。例如:如果函数返回any,则使用其返回值的所有表达式类型也将变成any。

所以你应该尽量避免使用any类型。幸运的是,TypeScript3.0引入了类型安全的替代方案——unknown。可以将任何值赋给unknown类型的变量,但是不能将unknown类型的变量的值赋给任何变量(这点不同于any)。

如果你的函数返回的是unknown类型的值,则调用方需要执行检查(使用类型保护),或至少将值显式转换为某个特定类型。(译者注:如果对这段不理解,可以参考下这篇文章,unknown 类型 中的示例部分)

let foo: any;

// anything can be assigned to foo
foo = 'abc';
// foo can be assigned to anything
const x: number = foo;


let bar: unknown;

// anything can be assigned to bar
bar = 'abc';
// COMPILE ERROR! Type 'unknown' is not assignable to type 'number'.
const y: number = bar;

使用unknown类型有时会有些麻烦,但是这也会让代码更易于理解,并且让你在开发时更加注意。

另外,你需要开启noImplicitAny,每当编译器推断某个值的类型为any时就会抛出错误。换句话说,它让你显式的标注出所有会出现any的场景。

尽管最终目标还是消除有any的情况,但明确申明any仍然是有益的:例如在code review时可以更容易捕获他们。

* 不要使用any类型并开启noImplicitAny

3. 开启strictNullChecks

你已经见过多少次这样的报错信息了?

TypeError: undefined is not an object

我打赌有很多次了,JavaScript(甚至是软件编程)中最常见的bug来源之一就是忘记处理空值。

在JavaScript中用null或undefined来表示空值。开发者们经常乐观的认为给定的变量不会是空的,于是就忘记处理空值的情况。

function printName(person: Person) {
  console.log(person.name.toUpperCase());
}

// RUNTIME ERROR!  TypeError: undefined is not an object   
// (evaluating 'person.name') 
printName(undefined);

通过开启strictNullChecks,编译器会迫使你去做相关的检查,这对防止出现这种常见问题起到了重要的作用。

默认情况下,typescript的每个类型都包含null和undefined这两个值。也就是说,null和undefined可以被赋值给任意类型的任何变量。

而开启strictNullChecks会更改该行为。由于无法将undefined作为Person类型的参数传递,因此下方的代码会在编译时报错。

// COMPILE ERROR! 
// Argument of type 'undefined' is not assignable to parameter of type 'Person'. printName(undefined); 

那如果你确实就想将undefined传递给printName怎么办?那你可以调整类型签名,但是仍然会要求你处理undefined的情况。

function printName(person: Person | undefined) {
  // COMPILE ERROR!
  // Object is possibly 'undefined'. 
     console.log(person.name.toUpperCase());
}

你可以通过确保person是被定义的来修复这个错误:

function printName(person: Person | undefined) { 
    if (person) {
        console.log(person.name.toUpperCase());
    }
} 

不幸的是,strictNullChecks默认是不开启的,我们需要在tsconfig.json中进行配置。

另外,strictNullChecks是更通用的严格模式的一部分,可以通过strict标志启用它。你绝对应该这样做!因为编译器的设置越严格,你就可以尽早发现更多bug。

* 始终开启strictNullChecks

4. 开启strictPropertyInitialization

strictPropertyInitialization是属于严格模式标志集的另一个标志。尤其在使用Class时开启strictPropertyInitialization很重要,它其实有点像是对strictNullChecks的扩展。

如果不开启strictPropertyInitialization的话,TS会允许以下的代码:

class Person {
  name: string;
  sayHello() {
    // RUNTIME ERROR!
    console.log( `Hello from ${this.name.toUpperCase()}`);
  }
} 

这里有个很明显的问题:this.name没有被初始化,因此在运行时调用sayHello就会报错。

造成这个错误的根本原因是这个属性没有在构造函数里或使用属性初始化器赋值,所以它(至少在最初)是undefined,因此他的类型就会变成string | undefined。

开启strictPropertyInitialization会提示以下错误:

Property 'name' has no initializer and is not assigned in the constructor. 

当然,如果你在构造函数里或使用属性初始化器赋值了,这个错误也就会消失。

* 始终开启strictPropertyInitialization

5. 记得指定函数的返回类型

TypeScript使你可以高度依赖类型推断,这意味着只要在TS能推断类型的地方,你就不需要标注类型。

然而这就像一把双刃剑,一方面,它非常方便,并且减少了使用TypeScript的麻烦。而另一方面,有时推断的类型可能会和你的预期不一致,从而降低了使用静态类型提供的保障。

在下方的例子中,我们没有注明返回类型,而是让TypeScript来推断函数的返回值。

interface Person {
    name: string;
    age: number;
}

function getName(person: Person | undefined) {
    if (person && person.name) {
        return person.name;
    } else if (!person) {
        return "no name";
    }
}

乍看之下,我们可能认为我们的方法很安全,并且始终返回的是string类型,然而,当我们明确声明该函数的(预期)返回类型时就会发现报了一个错。

// COMPILE ERROR! 
// Function lacks ending return statement and return type does not include 'undefined'. 
function getName(person: Person | undefined): string 
{
    // ... 
}

顺便说一句,这个错误只有当你开启了strictNullChecks才会被检测出来。

上述错误表明getName函数的返回值没有覆盖到一种情况:当person不为空,但是person.name为空的情况。这种情况所有if条件都不等于true,所以会返回undefined。

因此,TypeScript推断此函数的返回类型为string | underfined,而我们声明的却是string。(译者注:所以主动声明函数返回值类型有助于帮我们提前捕捉一些不易察觉的bug)

* 始终标注函数的返回值类型

6. 不要将隐式类型变量存储到对象中

TypeScript的类型检查有时很微妙。

通常,当类型A至少具有和类型B相同的属性,那么TypeScript就允许将类型A的对象赋值给类型B的变量。这意味着它可以包含其他属性。

// 译者举例:
type A = {
    name: string;
    age: number;
};

type B = {
    name: string;
};

let a: A = {
    name: 'John',
    age: 12,
};

let b: B;

// compile success
b = a;

然而如果直接传递的是对象字面量,其行为是不同的。只有目标类型包含相同的属性时,TypeScript才会允许它(传递)。此时不允许包含其他属性。

interface Person {
    name: string;
}

function getName(person: Person): string | undefined {
    // ...
}

// ok
getName({ name: 'John' });

// COMPILE ERROR
// Argument of type '{ name: string; age: number; }' is not assignable to parameter of type 'Person'.
getName({ name: 'John', age: 30 });

如果我们不是直接传对象字面量,而是将对象存到常量里(再传递),这看起来没有什么区别。然而这却更改了类型检查的行为:

const person = { name: 'John', age: 30 }; 
// OK 
getName(person); 

传递额外的属性可能会引起bug(例如当你想合并两个对象时)。了解这个行为并且在可能的情况下,直接传递对象字面量。

* 请注意如何将对象传递给函数并且始终要考虑传递额外的属性是否安全

7. 不要过度使用类型断言

尽管TypeScript能对你的代码进行很多推断,但有时候你会比TypeScript更了解某个值的详细信息。这时你可以通过类型断言这种方式可以告诉编译器,“相信我,我知道自己在干什么”。

比如说对一个从服务器请求回来的对象断言,或者将一个子类型的对象断言为父类型。

类型断言需要保守使用。比如绝对不能在函数传参类型不匹配时使用。

有一种更安全的使用类型断言的方式:类型保护。类型保护是一个当返回true时能断言其参数类型的函数。它可以提供代码运行时的检测,让我们对传入的变量是否符合预期这点上更有信心。

下面的代码中,我们需要使用类型断言,因为TypeScript不知道从后端返回的对象的类型。

interface Person {
    name: string;
    age: number;
}

declare const fetchFromBackend: (url: string) => Promise<object>;

declare const displayPerson: (person: Person) => void;

fetchFromBackend('/person/1').then((person) => displayPerson(person as Person));

我们可以通过使用类型保护,提供一个简单的运行时检查来让代码更完善。我们假设一个对象只要拥有了nameage属性那么它的类型就是Person

const isPerson = (obj: Object): obj is Person => 'name' in obj && 'age' in obj;

fetchFromBackend('/person/1').then((person) => {
  if(isPerson(person)) {
    // Type of `person` is `Person` here!
    displayPerson(person);
  }
})

你可以发现,多亏了类型保护,在if语句中person的类型已经可以被正确推断了。

* 考虑使用类型保护来替代类型断言

8. 不要对Partial类型使用扩展运算符

Partial是一个非常有用的类型,它的作用是将源类型的每个属性都变成可选的。

Partial有个好的实际使用场景:当你有一个表示配置或选项的对象类型,并且想要创建一个该配置对象的子集来覆写它。

你可能会写出如下的代码:

interface Settings {
  a: string;
  b: number;
}

const defaultSettings: Settings = { /* ... */ }; 

function getSettings(overrides: Partial<Settings>): Settings {
  return { ...defaultSettings, ...overrides };
}

这看起来还不错,但实际上揭示了TypeScript的类型系统中的一个漏洞。

看下方的代码,result的类型是Settings,然而result.a的值却是undefined了。

const result = getSettings({ a: undefined, b: 2 });

由于扩展Partial是一种常见的模式,并且TypeScript的目标之一是在严格性和便利性之间取得平衡,所以可以说是TypeScript本身的设计带来了这种不一致性。但是,意识到该问题仍然非常重要。

* 除非你确定对象里不包含显式的undefined,否则不要对Parital对象使用扩展运算符

9. 不要过于相信Record类型

这是TypeScript内置类型定义中的一个微妙情况的另一个示例。

Record定义了一个对象类型,其中所有key具有相同的类型,所有value具有相同的类型。 这非常适合表示值的映射和字典。

换句话说,Record<KeyType, ValueType> 等价于 { [key: KeyType]: ValueType }

从下方代码你可以看出,通过访问record对象的属性返回的值的类型应该和ValueType保持一致。然而你会发现这不是完全正确的,因为abc的值会是undefined。

const languages: Record<string, string> = {
    'c++': 'static',
    'java': 'static',
    'python': 'dynamic',
};


const abc: string = languages['abc']; // undefined

这又是一个TypeScript选择了便利性而不是严格性的例子。虽然大多数例子中这样使用都是可以的,但是你仍然要小心些。

最简单的修复方式就是使Record的第二个参数可选:

const languages: Partial<Record<string, string>> = {
    'c++': 'static',
    'java': 'static',
    'python': 'dynamic',
};

const abc = languages['abc']; // abc is infer to string | underfined

* 除非你确保没问题,否则可以始终保持Record的值类型参数(第二个参数)可选

10. 不要允许出现不合格的类型声明

在定义业务域对象的类型时,通常会遇到类似以下的情况:

interface Customer {
    acquisitionDate: Date;
    type: CustomerType;
    firstName?: string;
    lastName?: string;
    socialSecurityNumber?: string;
    companyName?: string;
    companyTaxId?: number;
}

这个对象包含很多可选的对象。其中一些对象是当Customer表示人时(type === CustomerType.Individual)才有意义且必填,另外的则是当Custormer表示公司时(type === CustomerType.Institution)必填。

问题在于Customer类型不能反映这一点! 换句话说,它允许属性的某些非法组合(例如,lastName和companyName都未定义)

这确实是有问题的。 你要么执行额外的检查,要么使用类型断言来消除基于type属性值的某些字段的可选性。

幸运的是,有一个更好的解决方案——辨析联合类型。辨析联合类型是在联合类型的基础上增加了一个功能:在运行时可以区分不同的方案。

我们将Customer类型重写为两种类型:IndividualInstitution的联合,各自包含一些特定的字段,并且有一个共有字段:type,它的值是一个字符串。此字段允许运行时检查,并且TypeScript知道可以专门处理它。

interface Individual {
  kind: 'individual';
  firstName: string;
  lastName: string;
  socialSecurityNumber: number;
}

interface Institution {
  kind: 'institutional';
  companyName: string;
  companyTaxId: number;
}

type Customer = Individual | Institution;

辨析联合类型真正酷的地方是TypeScript提供了内置的类型保护,可以让你避免类型断言。

function getCustomerName(customer: Customer) {
  if (customer.kind === 'individual') {
    // The type of `customer` id `Individual`
    return customer.lastName;
  } else {
    // The type of `customer` id `Institution`
    return customer.companyName;
  }
}

* 当遇到复杂的业务对象时尽量考虑使用辨析联合类型。这可以帮你创建更贴合现实场景的类型

文章到此结束了!我希望这个列表可以像帮助我一样,帮助你捕获许多讨厌的bug。

接下来是这篇文章所有建议的总结:

  1. 考虑对所有外部提供的对象执行运行时检查(例如从后端获取的对象,JSON反序列化的对象等)
  2. 不使要用any类型并开启noImplicitAny
  3. 始终开启strictNullChecks
  4. 始终开启strictPropertyInitialization
  5. 始终标注函数的返回值类型
  6. 请注意如何将对象传递给函数并且始终要考虑传递额外的属性是否安全
  7. 考虑使用类型保护来替代类型断言
  8. 除非你确定对象里不包含显式的undefined,否则不要对Parital对象使用扩展运算符
  9. 除非你确保没问题,否则可以始终保持Record的值类型参数(第二个参数)可选
  10. 当遇到复杂的业务对象时尽量考虑使用辨析联合类型。
阅读 2.6k

推荐阅读
众安前端
用户专栏

555 人关注
2 篇文章
专栏主页
目录