Things I Learned (TypeScript)

esModuleInterop🔗

TypeScript

以 React 的使用为例,在 commonjs 的环境下,React 的引用方式是:

const React = require('react');

而在 ES5 的语法中,React 官方使用的引用方式是:

import React from 'react';

(见 create-react-app 中生成的代码)

这就造成了一个问题:require('react') 这样的语法,对应的 import 语法应该怎么写?显然,require 语法无法直接转译成 import 语法:因为根据 import 的语法规则,import React from 'react'; 实际是将 default 引入,而 require('react') 的时候,并没有 .default 字段参与。

无论是 Webpack,Babel 还是 TypeScript,在这个问题上都采取了相同的策略,就是在将 import 转译成 require 语句的时候,多套一层:如果 require 的部分有 .default 字段,就使用这个字段;否则的话,就将整体当作是 .default 的值。

在 Webpack 中,使用的是 __webpack_require.n 这个函数:

__webpack_require__.n = function(module) {
  var getter = module && module.__esModule ?
    function getDefault() { return module['default']; } :
    function getModuleExports() { return module; };
  __webpack_require__.d(getter, 'a', getter);
  return getter;
};

对于 Babel,使用的是 _interopRequireDefault 这个函数:

function _interopRequireDefault(obj) {
  return obj && obj.__esModule ? obj : { default: obj };
}
var react = _interopRequireDefault(require('react'));

对于 TypeScript 来说,在 compilerOptions 中增加 esModuleInterop 这个参数,就可以让 tsc 在编译 import 代码的时候进行一层包转转换,使用 __importDefault 确保无论是否是 ES6 module 的输出,都可以被正确的 require:

var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
const react = __importStar(require("react"));

如果编译结果需要在 Node.js 环境下运行(如进行 Jest 单元测试等),可以考虑上面的配置方案。


npmignore .ts but keep .d.ts🔗

TypeScript

在某些情况下,可能希望将 .ts 文件从 npm 打包中去除(因为不会使用到未编译的代码),但是却希望保留 .d.ts 文件用于帮助使用者获得更好的类型判断。

因为 .npmignore 支持 glob 的语法,因而可以写类似如下的代码来满足需求:

# ignore the .ts files
*.ts

# include the .d.ts files
!*.d.ts

这里 ! 表示“不包含”,同时因为 .npmignore 文件的含义是定义不打包的文件,因此“负负得正”,这些文件最终会被保留到 npm 的包产物中。


Export was Not Found🔗

TypeScript

在使用 TypeScript + Webpack 的项目中,可能会遇到如下类似的报错:

WARNING in ./src/xxx.tsx 346:0-62
"export 'xxx' was not found in './xxxx'

这类报错出现的情况是,在 ./scr/xxx.tsx 文件中,先 import 了一个类型定义,然后又将这个类型定义重新 export 出去了。产生报错的原因在于,TypeScript 的文件需要通过 loader(无论是 babel-loader 还是 ts-loader)转化成 Webpack 可识别的 JavaScript 文件。在转化之后,TypeScript 中定义的纯类型(如 interface)都丢失了。正因为这些类型丢失了,在试图重新 export 的时候,Webpack 就无法找到对应的定义,只能报错(Warning)了。

可以考虑通过以下的方案避免警告:

  1. 将所有的类型定义放到单独的文件(比如 types.ts 中),然后通过 export * from 'types.ts' 一次性将所有内容 export 出去(这样可以避免具体声明需要 export 的内容);
  2. 重新在当前文件中定义一个类型,然后将这个类型 export 出去:
import { Type as _Type } from './type';
export type Type = _Type;

在 TypeScript 3.7 之前,上面的代码可以简写为:

import { Type } from './type';
export type Type = Type;

在 3.7 及之后的版本中,必须保证新定义的类型名称和原来的类型名称不同。这是因为在 TypeScript 3.7 中对类型定义做了调整,在提供更强大的递归引用类型功能的同时,不再允许定义同名的类型。相关的介绍,可以查看官方的发布文档


Export Variable using Private Name🔗

TypeScript

在 TypeScript 编译过程中,可能会遇到如下的报错:

Exported variable <variable name> has or is using private name <private name>

这一报错只会出现在开启了 declaration 输出之后。开启的方式是编译时增加 --declaration,或者在 tsconfig.json 中加入:

{
  "compilerOptions": {
    "declaration": true
  }
}

出现这一报错的原因是,最终被使用的某一个类型,引用到了某一个没有被公开(export)的类型。简单的例子如下:

interface A {
  // ...
}

interface B {
  // ...
}

export declare type Props = A | B;

这里,之所以会出现问题,理由很简单:TypeScript 试图输出一个定义类型的文件,其中就包括了 Props 的定义。然而,如果要明确定义 Props,就需要用到两个类型 AB。在这里,AB 这两个类型因为没有被公开(export),因而是私有(private)的。故,理论上来说,TypeScript 的导出定义文件中不应该包含这两个类型。而没有这两个类型的话,TypeScript 就没有办法定义 Props 了。最终,TypeScript 只能报错。

官方给出的解释可以参考这里

要解决这个问题,方法也很简单:所有使用到的类型,全部都公开(export)就好了。


Nullish Coalescing🔗

TypeScript

Nullish Coalescing 当前在 TC39 Stage 3 的阶段,TypeScript 在 3.7 中也将这一功能引入了进来。(Coalesce 是“合并;联合;接合”的意思)

Nullish Coalescing 的简单用法如下:

let x = foo ?? bar();

foo 的值是 null 或者 undefined 的时候,x 的值由后面的 bar() 决定,否则 x 的值就是 foo 本身。这一行为,一般会被用于给变量赋初始值。在之前的 JavaScript / TypeScript 中,一般会这么写:

function getNumber(num: number) {
  return num || 5;
}

但是,这样写有一个问题,就是当 num 的值是 0 的时候,最终的值依然是 5 而不是 0。这一行为很可能并不是开发者希望的。

和 Optional Chaining 一样,Nullish Coalescing 只有在原值是 null 或者 undefined 的时候,才进行操作;其他的 falsy 值,都会保持原样,并不会做特殊的处理(根据 Proposal 中的说明,这两个规范将会在“何时处理”上保持一致)。这很大程度上减少了 JavaScript 在类型上导致隐藏问题的可能性。

需要注意的一点是,这一行为和 JavaScript 中的默认参数是有一点不一样的。上面的代码如果改写成默认参数的形式:

function getNumber(num: number = 5) {
  return num;
}

那么,将会在 getNumber(null) 的时候产生行为上的分歧。使用 Nullish Coalescing 将会返回 5,也就是进行了默认值赋值;而默认参数的方案将会返回 null,因为默认参数只有在 undefined 的情况下才会进行默认值赋值操作。

Optional Chaining 和 Nullish Coalescing 可以放在一起操作,确保值不存在的时候,有一个兜底的默认值可以给程序使用:

let x = foo?.bar?.() ?? 'default';

Optional Chaining in TypeScript🔗

TypeScript

在 TC39 将 Optional Chaining 转移到 Stage 3 之后,TypeScript 在 3.7 版本中也带来了对应的 Optional Chaining 功能。总体上,TypeScript 的 Optional Chaining 功能和 JavaScript 的提案是保持一致的。总结来说,就是:

如果属性值是 undefined 或者 null,就会直接返回 undefined,否则会进一步获取真实的属性值。

TypeScript Playground 中可以尝试一下这个新的功能。以下面这段 TypeScript 为例:

let x = foo?.bar?.();

最终会被转译成下面的这段 JavaScript:

"use strict";
var _a, _b, _c;
let x = (_c = (_a = foo) === null || _a === void 0 ?
  void 0 :
  (_b = _a).bar) === null || _c === void 0 ?
    void 0 :
    _c.call(_b);

几点简单的说明:

  1. 即使值是 null,最终返回的结果也会是 undefined(上面代码中是 void 0,是等价的);
  2. 只有 nullundefined 的情况会被直接返回。这一点,和之前 foo && foo.bar 这样的写法是有区别的。主要是 JavaScript 对哪些值是 falsy 的判断,范围会比 null & undefined 更广,还包括了 NaN0false 等;
  3. Optional Chaining 在函数调用中也是可以用的,写法是 xx?.(),如果不存在,函数不会调用,而是直接返回 undefined
  4. Optional Chaining 也可以使用如下的写法:foo?.[0]foo?.['var-name']foo?.[variableName]

官方的发布介绍文档见这里


Conditional Props in React🔗

TypeScript

在 React 中,经常会有这样的场景:通过某一个参数是否是真值,来决定某一个元素是否需要显示出来。

以 Ant Design 为例,Tooltip 的定义中,就包含了 title 这个参数,用于决定是否显示 Tooltip 及显示什么。如果传递的是 falsenull 或者 undefined,那么最终 Tooltip 就不会被显示出来。

常用的调用形式可能如下:

<Tooltip title={!this.state.hide && 'text'} />

在最初 Ant Design 对此的定义上,使用了如下的 TypeScript 类型定义:

interface Props {
  // ...
  title?: React.ReactNode | RenderFunction;
  // ...
}

这里,title 的定义用到了“可选参数”。看上去,是符合预期的行为,然而这里有几个细节值得注意:

  1. React.ReactNode 的定义是:
type ReactNode =
  ReactChild | ReactFragment | ReactPortal | boolean | null | undefined;

可以看到,即使不是可选参数,undefined 等一系列值也是可以赋予给 title 的;

  1. title?: stringtitle: string | undefined 之间存在着细微的差别。

这里展开对比一下 title?: stringtitle: string | undefined 之间的细微差别。如果定义的类型是 title?: string,那么,以下的调用方式都是正确的:

  1. 传递字符串作为参数:
<Tooltip title="string" />
  1. 传递 undefined 作为参数:
<Tooltip title={undefined} />
  1. 不传递参数:
<Tooltip />

而如果是 title: string | undefined,那么上面的第三种方案(即不传参数)就是不可行的。

还是以 Tooltip 为例,显然前两种调用方法都是真实存在的场景,毕竟 Tooltip 可能是需要根据外部条件来选择性展示的;但是对于第三种场景,即不提供 title 数据、一直保持不渲染 Tooltip 的状态,可以认为是有错误的,应该由 TypeScript 进行检查并报错。

故,改成以下这种形式就可以了,毕竟 React.ReactNode 就允许了 undefined 的使用:

interface Props {
  // ...
  title: React.ReactNode | RenderFunction;
  // ...
}

Ant Design 对这种情况进行了修正


Derive Union Type from Tuple/Array🔗

TypeScript

在 TypeScript 中,如果希望一个变量只能取某几个固定值中的一个,可以这么写:

type Type = 'a' | 'b';
const a: Type = 'a'; // ✔
const c: Type = 'c'; // ✖

然而,在实际的开发过程中,可能会遇到这样的需求:希望 TypeScript 可以限定某一个类型只能取某几个固定的值,同时这几个值又可以组成一个数组,方便 JavaScript 在运行时动态的执行匹配功能(如 Array.prototype.some)。

如果直接尝试在 TypeScript 中写数组,实际无法达到预想的效果:

const list = ['a', 'b'];
type Type = list[number]; // Type = string

这是因为,TypeScript 默认 list 的类型是 string[],而不是 ('a' | 'b')[]。因此,在转化成 Type 的时候,得到的结果是更宽泛的字符串类型,而不是限定死的两个固定值。这其中,一个很重要的原因是 JavaScript 语言的动态性。数组随时可以被加入/删除元素,因而默认只能假设这是一个字符串类型的数组,而不能过多约束。

为了达到目的,有以下几个变通的写法:

const list: ['a', 'b'] = ['a', 'b'];
type Type = list[number]; // Type = 'a' | 'b';

这种写法比较啰嗦,重新写了一遍完整的数组用于定死类型的选择范围。

也可以通过写一个辅助函数来达到类似的效果:

declare const tupleStr: <T extends string[]>(...args: T) => T;
const list = tupleStr('a', 'b');
type Type = list[number]; // Type = 'a' | 'b';

在 Ant Design 中可以找到类似的写法。这里也有一个类似的 gist

注:上述这种写法需要 TypeScript 3.0 的支持

当然,上述的方案或多或少都需要额外写一些东西,有些麻烦。在 TypeScript 3.4 中,可以通过 as const 这个语法来告知 TypeScript 数组是静态的、并不会增加或者减少内容。有了这样的前提假设,TypeScript 就可以更好的进行类型推导,把实际的类型结果限制到已知的几个有限的值范围内。例子如下:

const list1 = ['a', 'b'] as const;
const list2 = <const> ['a', 'b'];
type Type1 = list1[number];
type Type2 = list2[number];

上述两种写法是等价的(参考这里),都可以达到目的。另外,由于在 TypeScript 中限制了数组,之后想要在数组中做改动都是会导致编译器报错的。

参考


TypeScript Non-null Assertion🔗

TypeScript

在 TypeScript 中,常常存在一个对象可能是 undefinednull 的情况。如果试图直接使用这样的对象,很可能会造成 TypeScript 的报错(在 --strictNullChecks 开启的情况下)。这本身是一个正确的行为,也可以在编译时帮助开发者避免一些不必要的错误。

然而,在实际的开发中,不免遇到这样的情况:在某些特定的生命周期中,开发者可以很明确的知道某一个值不会是 undefined 或者 null。然而,这样的前置条件 TypeScript 本身并不知情。此时,为了防止 TypeScript 报错,就需要通过某些显式的方法,声明这一情况。

最常见的方案,是通过 as 来强制类型转化。比如:

function throwIfUndefined(input: any) {
  if (typeof input === 'undefined') throw new Error('Undefined!');
}
function handler(optional?: string) {
  throwIfUndefined(optional);
  console.log((optional as string).length);
}

TypeScript 提供了一个 Non-null assertion 运算符:!.,就是用于上述情景的。具体来说:

function handler(optional?: string) {
  throwIfUndefined(optional);
  console.log(optional!.length);
}

上面这样写之后,TypeScript 就不会报错了。optional 被认为一定是非 undefinednull 类型的。(至于 TypeScript 会认为这个变量是什么类型的,就要看这个变量除了 undefined | null 的类型之外,还可能是什么类型的了)

当然,不难看出,这个运算符只是一个编译时帮助编译器理解类型用的辅助手段,本身并不是一个语法糖。因此,在 TypeScript 转化成 JavaScript 的过程中,这里的运算符会直接被去掉。optional!.length 生成的就是 optional.length,没有生成任何额外的东西。


Record in TypeScript🔗

TypeScript

Record 是 TypeScript 中一个很实用的范型类型。它需要两个具体的参数类型,Record<K, V>,用于指定一个对象的类型。其中,对象的所有 key 都是 K 类型的,而这些 key 对应的值则都是 V 类型的。如果不使用 Record 类型,可能需要用如下的方法来达到同等的效果:

type RecordExample = Record<string, number>;
interface EquivalentExample {
  [key: string]: number;
}

显然,等价的写法更为的复杂,看起来也不那么清晰。

当然,对于 JavaScript 来说,对象的属性其实只能是 string 类型的。虽然有时候也会直接使用 number 作为值(TypeScript 里面也可以专门这么来做类型强制),但是其实在用作 key 的时候,会经过一步 toString 的转化。比如:

const obj = { key: 'value' };
const key = { toString() { return 'key'; }};
console.log(obj[key]); // output: value

这么看起来,Record 的应用场景似乎非常有限,只有 Record<string, xxx> 或者 Record<number, xxx> 两种可能性。然而,TypeScript 中除了可以使用一些泛用的类型之外,也可以对类型做更进一步的限定。比如,指定类型只能是 'apple' | 'banana' | 'orange'。如此一来,Record 就有了更多的应用场景。

举例来说,如果希望写一个函数,可以将参数对象中所有的值都转化成对应的数字,就可以这么写:

type Input = Record<string, string>
function transform<T extends Input>(input: T): Record<keyof T, number> {
  const keys: (keyof T)[] = Object.keys(input);
  return keys.reduce((acc, key) => {
    acc[key] = +input[key];
    return acc;
  }, {} as Record<keyof T, number>);
}

这样,就可以保证输入和输出的对象,有相同的 key。

然而,需要注意的一点是,在使用联合类型的时候 Record 本身也存在局限性(这一点本身是 TypeScript 的局限性)。还是以上面的 'apple' | 'banana' | 'orange' 为例,如果这么写,那么下面的代码将是错误的:

type Fruit = 'apple' | 'banana' | 'orange';
type Price = Record<Fruit, number>;
// type error
const prices: Price = {
  apple: 20
};

原因是,上面只定义了 apple 的数据,但是没有定义剩下的 bananaorange 的数据。以下定义不会报错,但有时候并不满足需求:

const prices: Price = {
  apple: 20,
  banana: 30,
  orange: 40,
};

Record 天然并不能解决可选 key 的情况。Record<'A' | 'B', number> 的含义是 AB 都需要是这个类型的 key,而不是说只需要有 AB 一个做 key 就可以了。对于这种需要可选的情况,可以再套上一层 Partial 来满足需求:

type Price = Partial<Record<Fruit, number>>;
// correct
const prices: Price = {
  apple: 20,
};

更多实现的细节,可以参考 Record 定义Partial 定义


Extract all function properties from given type🔗

TypeScript

假设有一个 TypeScript 的类型是:

interface Example {
  str: string;
  num: number;
  func1: (param1: string, param2: number) => null;
  func2: () => void;
}

以下这个 TypeScript 的定义,可以用于将 T 中函数的部分抽离出来,形成新的类型:

type Pick<T, K extends keyof T> = {
  [P in K]: T[P];
};

type FunctionPropertyNames<T> = {
  [K in keyof T]: T[K] extends Function ? K : never;
}[keyof T];

最终,新类型的定义如下:

type Result = Pick<Example, FunctionPropertyNames<Example>>;

等价于:

interface Equivalent {
  func1: (param1: string, param2: number) => null;
  func2: () => void;
}