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

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

其我的博客https://codewithstyle.info也有许多关于TS的文章能够学习。java

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

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

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

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

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

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();
      })
}

能够看到接口定义在编译后已经彻底消失了,并且这里也不会有任何验证性的代码。typescript

不过你最好能够本身去执行运行时校验,许多库(译者注:io-ts)能帮你作到这点。不过,请记住,这必定会带来性能开销。编程

* 考虑对全部外部提供的对象执行运行时检查(例如从后端获取的对象,JSON反序列化的对象等)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. 当遇到复杂的业务对象时尽可能考虑使用辨析联合类型。
相关文章
相关标签/搜索