这是 TypeScript 基础的第二篇,上一篇 TypeScript 入门系列 | TypeScript 基础(一) 讲了:
基础类型
接口
类
函数
泛型
枚举
这节介绍 TypeScript 里的类型推论。即,类型是在哪里如何被推断的。
当需要从几个表达式中推断类型时候,会使用这些表达式的类型来推断出一个最合适的通用类型。
let x = [0, 1, null];
为了推断x的类型,我们必须考虑所有元素的类型。这里有两种选择:number和null。计算通用类型算法会考虑所有的候选类型,并给出一个兼容所有候选类型的类型。
let zoo = [new Rhino(), new Elephant(), new Snake()];
这里,我们想让zoo被推断为Animal[]类型,但是这个数组里没有对象是Animal类型的,因此不能推断出这个结果。为了更正,当候选类型不能使用的时候我们需要明确的指出类型:
let zoo: Animal[] = [new Rhino(), new Elephant(), new Snake()];
如果没有找到最佳通用类型的话,类型推断的结果为联合数组类型,(Rhino | Elephant | Snake)[]
。
相对来讲,在比较原始类型和对象类型的时候是比较容易理解的,问题是如何判断两个函数是兼容的。
let x = (a: number) => 0;
let y = (b: number, s: string) => 0;
y = x; // OK
x = y; // Error
x 的每个参数必须能在 y 里找到对应类型的参数。注意的是参数的名字相同与否无所谓,只看它们的类型。这里,x 的每个参数在 y 中都能找到对应的参数,所以允许赋值。
第二个赋值错误,因为 y 有个必需的第二个参数,但是 x 并没有,所以不允许赋值。
下面来看看如何处理返回值类型,创建两个仅是返回值类型不同的函数。
let x = () => ({name: 'Alice'});
let y = () => ({name: 'Alice', location: 'Seattle'});
x = y; // OK
y = x; // Error, because x() lacks a location property
不太懂
当比较函数参数类型时,只有当源函数参数能够赋值给目标函数或者反过来时才能赋值成功。这是不稳定的,因为调用者可能传入了一个具有更精确类型信息的函数,但是调用这个传入的函数的时候却使用了不是那么精确的类型信息。
enum EventType { Mouse, Keyboard }
interface Event { timestamp: number; }
interface MouseEvent extends Event { x: number; y: number }
interface KeyEvent extends Event { keyCode: number }
function listenEvent(eventType: EventType, handler: (n: Event) => void) {
/* ... */
}
// Unsound, but useful and common
listenEvent(EventType.Mouse, (e: MouseEvent) => console.log(e.x + ',' + e.y));
// Undesirable alternatives in presence of soundness
listenEvent(EventType.Mouse, (e: Event) => console.log((<MouseEvent>e).x + ',' + (<MouseEvent>e).y));
listenEvent(EventType.Mouse, <(e: Event) => void>((e: MouseEvent) => console.log(e.x + ',' + e.y)));
// Still disallowed (clear error). Type safety enforced for wholly incompatible types
listenEvent(EventType.Mouse, (e: number) => console.log(e));
对于有重载的函数,源函数的每个重载都要在目标函数上找到对应的函数签名。
枚举
枚举类型与数字类型兼容,并且数字类型与枚举类型兼容。不同枚举类型之间是不兼容的。
enum Status { Ready, Waiting };
enum Color { Red, Blue, Green };
let status = Status.Ready;
status = Color.Green; // Error
类与对象字面量和接口差不多,但有一点不同:类有静态部分和实例部分的类型。比较两个类类型的对象时,只有实例的成员会被比较。静态成员和构造函数不在比较的范围内。
class Animal {
feet: number;
constructor(name: string, numFeet: number) { }
}
class Size {
feet: number;
constructor(numFeet: number) { }
}
let a: Animal;
let s: Size;
a = s; // OK
s = a; // OK
泛型
interface Empty<T> {
}
let x: Empty<number>;
let y: Empty<string>;
x = y; // OK, because y matches structure of x
泛型类型在使用时就好比不是一个泛型类型。
interface NotEmpty<T> {
data: T;
}
let x: NotEmpty<number>;
let y: NotEmpty<string>;
x = y; // Error, because x and y are not compatible
交叉类型是将多个类型合并为一个类型。这让我们可以把现有的多种类型叠加到一起成为一种类型,它包含了所需的所有类型的特性。例如, Person & Serializable & Loggable
同时是 Person
和 Serializable
和 Loggable
我们大多是在混入(mixins)或其它不适合典型面向对象模型的地方看到交叉类型的使用。
function extend<T, U>(first: T, second: U): T & U {
let result = <T & U>{};
for (let id in first) {
(<any>result)[id] = (<any>first)[id];
}
for (let id in second) {
if (!result.hasOwnProperty(id)) {
(<any>result)[id] = (<any>second)[id];
}
}
return result;
}
class Person {
constructor(public name: string) { }
}
interface Loggable {
log(): void;
}
class ConsoleLogger implements Loggable {
log() {
// ...
}
}
var jim = extend(new Person("Jim"), new ConsoleLogger());
var n = jim.name;
jim.log();
联合类型与交叉类型很有关联,但是使用上却完全不同。它表示 A 类型或者 B 类型
合类型表示一个值可以是几种类型之一。我们用竖线( |
)分隔每个类型,所以 number
| string
| boolean
表示一个值可以是 number
, string
,或 boolean
。
如果一个值是联合类型,我们只能访问此联合类型的所有类型里共有的成员。
interface Bird {
fly();
layEggs();
}
interface Fish {
swim();
layEggs();
}
function getSmallPet(): Fish | Bird {
// ...
}
let pet = getSmallPet();
pet.layEggs(); // okay
pet.swim(); // errors
联合类型适合于那些值可以为不同类型的情况。但当我们想确切地了解是否为 Fish 时怎么办?JavaScript 里常用来区分2个可能值的方法是检查成员是否存在。如之前提及的,我们只能访问联合类型中共同拥有的成员。
let pet = getSmallPet();
// 每一个成员访问都会报错
if (pet.swim) {
pet.swim();
}
else if (pet.fly) {
pet.fly();
}
let pet = getSmallPet();
if ((<Fish>pet).swim) {
(<Fish>pet).swim();
}
else {
(<Bird>pet).fly();
}
用户自定义的类型保护
这里可以注意到我们不得不多次使用类型断言。假若我们一旦检查过类型,就能在之后的每个分支里清楚地知道 pet
的类型的话就好了。
TypeScript 里的类型保护机制让它成为了现实。类型保护就是一些表达式,它们会在运行时检查以确保在某个作用域里的类型。要定义一个类型保护,我们只要简单地定义一个函数,它的返回值是一个类型谓词:
function isFish(pet: Fish | Bird): pet is Fish {
return (<Fish>pet).swim !== undefined;
}
在这个例子里, pet is Fish
就是类型谓词。谓词为 parameterName is Type
这种形式, parameterName
必须是来自于当前函数签名里的一个参数名。
typeof
类型保护
现在我们不必将 typeof x === "number"
抽象成一个函数,因为 TypeScript 可以将它识别为一个类型保护。也就是说我们可以直接在代码里检查类型了。
function padLeft(value: string, padding: string | number) {
if (typeof padding === "number") {
return Array(padding + 1).join(" ") + value;
}
if (typeof padding === "string") {
return padding + value;
}
throw new Error(`Expected string or number, got '${padding}'.`);
}
这些 typeof类型保护 只有两种形式能被识别:typeof v === "typename"
和 typeof v !== "typename"
, "typename"
必须是 number
, string
, boolean
或 symbol
。但是 TypeScript 并不会阻止你与其它字符串比较,语言不会把那些表达式识别为类型保护。
instanceof
类型保护
instanceof
类型保护是通过构造函数来细化类型的一种方式。
interface Padder {
getPaddingString(): string
}
class SpaceRepeatingPadder implements Padder {
constructor(private numSpaces: number) { }
getPaddingString() {
return Array(this.numSpaces + 1).join(" ");
}
}
class StringPadder implements Padder {
constructor(private value: string) { }
getPaddingString() {
return this.value;
}
}
function getRandomPadder() {
return Math.random() < 0.5 ?
new SpaceRepeatingPadder(4) :
new StringPadder(" ");
}
// 类型为SpaceRepeatingPadder | StringPadder
let padder: Padder = getRandomPadder();
if (padder instanceof SpaceRepeatingPadder) {
padder; // 类型细化为'SpaceRepeatingPadder'
}
if (padder instanceof StringPadder) {
padder; // 类型细化为'StringPadder'
}
TypeScript 具有两种特殊的类型, null
和 undefined
,它们分别具有值 null
和 undefined
。
默认情况下,类型检查器认为 null
与 undefined
可以赋值给任何类型。null
与 undefined
是所有其它类型的一个有效值。这也意味着,你阻止不了将它们赋值给其它类型,就算是你想要阻止这种情况也不行。
--strictNullChecks
标记可以解决此错误:当你声明一个变量时,它不会自动地包含 null
或 undefined
。你可以使用联合类型明确的包含它们:
let s = "foo";
s = null; // 错误, 'null'不能赋值给'string'
let sn: string | null = "bar";
sn = null; // 可以
sn = undefined; // error, 'undefined'不能赋值给'string | null'
注意,按照 JavaScript 的语义,TypeScript 会把 null
和 undefined
区别对待。string | null
, string | undefined
和 string | undefined | null
是不同的类型。
可选参数和可选属性
使用了 --strictNullChecks
,可选参数会被自动地加上 | undefined
function f(x: number, y?: number) {
return x + (y || 0);
}
f(1, 2);
f(1);
f(1, undefined);
f(1, null); // error, 'null' is not assignable to 'number | undefined'
可选属性也会有同样的处理,使用了 --strictNullChecks
class C {
a: number;
b?: number;
}
let c = new C();
c.a = 12;
c.a = undefined; // error, 'undefined' is not assignable to 'number'
c.b = 13;
c.b = undefined; // ok
c.b = null; // error, 'null' is not assignable to 'number | undefined'
类型别名
类型别名会给一个类型起个新名字。类型别名有时和接口很像,但是可以作用于原始值,联合类型,元组以及其它任何你需要手写的类型。
type Name = string;
type NameResolver = () => string;
type NameOrResolver = Name | NameResolver;
function getName(n: NameOrResolver): Name {
if (typeof n === 'string') {
return n;
}
else {
return n();
}
}
起别名不会新建一个类型 - 它创建了一个新名字来引用那个类型。给原始类型起别名通常没什么用,尽管可以做为文档的一种形式使用。
接口 vs. 类型别名
type Alias = { num: number }
interface Interface {
num: number;
}
declare function aliased(arg: Alias): Alias;
declare function interfaced(arg: Interface): Interface;
类型别名可以像接口一样;然而,仍有一些细微差别。
其一,接口创建了一个新的名字,可以在其它任何地方使用。类型别名并不创建新名字—比如,错误信息就不会使用别名。在下面的示例代码里,在编译器中将鼠标悬停在 interfaced
上,显示它返回的是 Interface
,但悬停在 aliased
上时,显示的却是对象字面量类型。
另一个重要区别是类型别名不能被 extends
和 implements
(自己也不能 extends
和 implements
其它类型)。因为 软件中的对象应该对于扩展是开放的,但是对于修改是封闭的,你应该尽量去使用接口代替类型别名。
字符串字面量类型允许你指定字符串必须的固定值。在实际应用中,字符串字面量类型可以与联合类型,类型保护和类型别名很好的配合。通过结合使用这些特性,你可以实现类似枚举类型的字符串。
type Easing = "ease-in" | "ease-out" | "ease-in-out";
class UIElement {
animate(dx: number, dy: number, easing: Easing) {
if (easing === "ease-in") {
// ...
}
else if (easing === "ease-out") {
}
else if (easing === "ease-in-out") {
}
else {
// error! should not pass null or undefined.
}
}
}
let button = new UIElement();
button.animate(0, 0, "ease-in");
button.animate(0, 0, "uneasy"); // error: "uneasy" is not allowed here
function rollDie(): 1 | 2 | 3 | 4 | 5 | 6 {
// ...
}
你可以合并单例类型,联合类型,类型保护和类型别名来创建一个叫做 可辨识联合的高级模式,它也称做 标签联合或 代数数据类型。
TypeScript 则基于已有的 JavaScript 模式。它具有3个要素:
interface Square {
kind: "square";
size: number;
}
interface Rectangle {
kind: "rectangle";
width: number;
height: number;
}
interface Circle {
kind: "circle";
radius: number;
}
首先我们声明了将要联合的接口。每个接口都有 kind
属性但有不同的字符串字面量类型。kind
属性称做可辨识的特征或 标签。其它的属性则特定于各个接口。注意,目前各个接口间是没有联系的。下面我们把它们联合到一起
type Shape = Square | Rectangle | Circle;
function area(s: Shape) {
switch (s.kind) {
case "square": return s.size * s.size;
case "rectangle": return s.height * s.width;
case "circle": return Math.PI * s.radius ** 2;
}
}
使用索引类型,编译器就能够检查使用了动态属性名的代码。
function pluck(o, names) {
return names.map(n => o[n]);
}
下面是如何在 TypeScript 里使用此函数,通过 索引类型查询 和 索引访问操作符
function pluck<T, K extends keyof T>(o: T, names: K[]): T[K][] {
return names.map(n => o[n]);
}
interface Person {
name: string;
age: number;
}
let person: Person = {
name: 'Jarid',
age: 35
};
let strings: string[] = pluck(person, ['name']); // ok, string[]
编译器会检查 name
是否真的是 Person
的一个属性。本例还引入了几个新的类型操作符。首先是 keyof T
, 索引类型查询操作符。对于任何类型 T
, keyof T
的结果为 T
上已知的公共属性名的联合。
let personProps: keyof Person; // 'name' | 'age'
keyof Person
是完全可以与 'name' | 'age'
互相替换的。不同的是如果你添加了其它的属性到 Person
。
pluck(person, ['age', 'unknown']); // error, 'unknown' is not in 'name' | 'age'
第二个操作符是 T[K]
, 索引访问操作符。在这里,类型语法反映了表达式语法。这意味着 person['name']具有类型 Person['name'] — 在我们的例子里则为 string类型。然而,就像索引类型查询一样,你可以在普通的上下文里使用 T[K],这正是它的强大所在。你只要确保类型变量 K extends keyof T就可以了。
function getProperty<T, K extends keyof T>(o: T, name: K): T[K] {
return o[name]; // o[name] is of type T[K]
}
索引类型和字符串索引签名
keyof
和 T[K]
与字符串索引签名进行交互。如果你有一个带有字符串索引签名的类型,那么 keyof T会是 string。并且 T[string]为索引签名的类型:
interface Map<T> {
[key: string]: T;
}
let keys: keyof Map<number>; // string
let value: Map<number>['foo']; // number
关于术语的一点说明: 请务必注意一点, TypeScript 1.5 里术语名已经发生了变化。“内部模块”现在称做“命名空间”。“外部模块”现在则简称为“模块”,这是为了与 ECMAScript 2015 里的术语保持一致,(也就是说
”module X {
相当于现在推荐的写法namespace X {
)。
导出声明
任何声明(比如变量,函数,类,类型别名或接口)都能够通过添加export关键字来导出。
export interface StringValidator {
isAcceptable(s: string): boolean;
}
export const numberRegexp = /^[0-9]+$/;
导出语句
class ZipCodeValidator implements StringValidator {
isAcceptable(s: string) {
return s.length === 5 && numberRegexp.test(s);
}
}
export { ZipCodeValidator };
export { ZipCodeValidator as mainValidator };
重新导出
我们经常会去扩展其它模块,并且只导出那个模块的部分内容。重新导出功能并不会在当前模块导入那个模块或定义一个新的局部变量。
export class ParseIntBasedZipCodeValidator {
isAcceptable(s: string) {
return s.length === 5 && parseInt(s).toString() === s;
}
}
// 导出原先的验证器但做了重命名
export {ZipCodeValidator as RegExpBasedZipCodeValidator} from "./ZipCodeValidator";
或者一个模块可以包裹多个模块,并把他们导出的内容联合在一起通过语法:export * from "module"
。
export * from "./StringValidator"; // exports interface StringValidator
export * from "./LettersOnlyValidator"; // exports class LettersOnlyValidator
export * from "./ZipCodeValidator"; // exports class ZipCodeValidator
导入一个模块中的某个导出内容
import { ZipCodeValidator } from "./ZipCodeValidator";
let myValidator = new ZipCodeValidator();
可以对导入内容重命名
import { ZipCodeValidator as ZCV } from "./ZipCodeValidator";
let myValidator = new ZCV();
将整个模块导入到一个变量,并通过它来访问模块的导出部分
import * as validator from "./ZipCodeValidator";
let myValidator = new validator.ZipCodeValidator();
具有副作用的导入模块
尽管不推荐这么做,一些模块会设置一些全局状态供其它模块使用。这些模块可能没有任何的导出或用户根本就不关注它的导出。使用下面的方法来导入这类模块:
import "./my-module.js";
每个模块都可以有一个 default
导出。默认导出使用 default
关键字标记;并且一个模块只能够有一个 default
导出。需要使用一种特殊的导入形式来导入 default
导出。
// JQuery.d.ts
declare let $: JQuery;
export default $;
// App.ts
import $ from "JQuery";
$("button.continue").html( "Next Step..." );
export =
和 import = require()
CommonJS 和 AMD 的 exports
都可以被赋值为一个对象, 这种情况下其作用就类似于 es6 语法里的默认导出,即 export default
语法了。虽然作用相似,但是 export default
语法并不能兼容 CommonJS 和 AMD 的 exports
。
为了支持 CommonJS 和 AMD 的 exports
, TypeScript 提供了 export =
语法。
export =
语法定义一个模块的导出对象。这里的对象一词指的是类,接口,命名空间,函数或枚举。
若使用 export =
导出一个模块,则必须使用 TypeScript 的特定语法 import module = require("module")
来导入此模块。
// ZipCodeValidator.ts
let numberRegexp = /^[0-9]+$/;
class ZipCodeValidator {
isAcceptable(s: string) {
return s.length === 5 && numberRegexp.test(s);
}
}
export = ZipCodeValidator;
// Test.ts
import zip = require("./ZipCodeValidator");
要想描述非 TypeScript 编写的类库的类型,我们需要声明类库所暴露出的 API。
们叫它声明因为它不是“外部程序”的具体实现。它们通常是在 .d.ts文件里定义的。如果你熟 悉C/C++,你可以把它们当做 .h 文件。让我们看一些例子。
// node.d.ts
declare module "url" {
export interface Url {
protocol?: string;
hostname?: string;
pathname?: string;
}
export function parse(urlStr: string, parseQueryString?, slashesDenoteHost?): Url;
}
declare module "path" {
export function normalize(p: string): string;
export function join(...paths: any[]): string;
export let sep: string;
}
直接使用
/// <reference path="node.d.ts"/>
import * as URL from "url";
let myUrl = URL.parse("http://www.typescriptlang.org");
外部模块简写
假如你不想在使用一个新模块之前花时间去编写声明,你可以采用声明的简写形式以便能够快速使用它。
// declarations.d.ts
declare module "hot-new-module";
简写模块里所有导出的类型将是 any。
import x, {y} from "hot-new-module";
x(y);
模块声明通配符
某些模块加载器如 SystemJS 和 AMD 支持导入非 JavaScript 内容。它们通常会使用一个前缀或后缀来表示特殊的加载语法。模块声明通配符可以用来表示这些情况。
declare module "*!text" {
const content: string;
export default content;
}
// Some do it the other way around.
declare module "json!*" {
const value: any;
export default value;
}
导入
import fileContent from "./xyz.txt!text";
import data from "json!http://example.com/data.json";
console.log(data, fileContent);
“声明合并” 是指编译器将针对同一个名字的两个独立声明合并为单一声明。合并后的声明同时拥有原先两个声明的特性。任何数量的声明都可被合并;不局限于两个声明。
最简单也最常见的声明合并类型是接口合并。从根本上说,合并的机制是把双方的成员放到一个同名的接口里。
interface Box {
height: number;
width: number;
}
interface Box {
scale: number;
}
let box: Box = {height: 5, width: 6, scale: 10};
又如
interface Cloner {
clone(animal: Animal): Animal;
}
interface Cloner {
clone(animal: Sheep): Sheep;
}
interface Cloner {
clone(animal: Dog): Dog;
clone(animal: Cat): Cat;
}
合并之后
interface Cloner {
clone(animal: Dog): Dog;
clone(animal: Cat): Cat;
clone(animal: Sheep): Sheep;
clone(animal: Animal): Animal;
}
这个规则有一个例外是当出现特殊的函数签名时。如果签名里有一个参数的类型是单一的字符串字面量(比如,不是字符串字面量的联合类型),那么它将会被提升到重载列表的最顶端。
interface Document {
createElement(tagName: any): Element;
}
interface Document {
createElement(tagName: "div"): HTMLDivElement;
createElement(tagName: "span"): HTMLSpanElement;
}
interface Document {
createElement(tagName: string): HTMLElement;
createElement(tagName: "canvas"): HTMLCanvasElement;
}
合并提升之后
interface Document {
createElement(tagName: "canvas"): HTMLCanvasElement;
createElement(tagName: "div"): HTMLDivElement;
createElement(tagName: "span"): HTMLSpanElement;
createElement(tagName: string): HTMLElement;
createElement(tagName: any): Element;
}
与接口相似,同名的命名空间也会合并其成员。命名空间会创建出命名空间和值,我们需要知道这两者都是怎么合并的。
对于命名空间的合并,模块导出的同名接口进行合并,构成单一命名空间内含合并后的接口。
对于命名空间里值的合并,如果当前已经存在给定名字的命名空间,那么后来的命名空间的导出成员会被加到已经存在的那个模块里。
namespace Animals {
export class Zebra { }
}
namespace Animals {
export interface Legged { numberOfLegs: number; }
export class Dog { }
}
等同于
namespace Animals {
export interface Legged { numberOfLegs: number; }
export class Zebra { }
export class Dog { }
}
除了这些合并外,你还需要了解非导出成员是如何处理的。非导出成员仅在其原有的(合并前的)命名空间内可见。这就是说合并之后,从其它命名空间合并进来的成员无法访问非导出成员。
namespace Animal {
let haveMuscles = true;
export function animalsHaveMuscles() {
return haveMuscles;
}
}
namespace Animal {
export function doAnimalsHaveMuscles() {
return haveMuscles; // Error, because haveMuscles is not accessible here
}
}
推荐阅读
TypeScript 入门系列 | TypeScript 基础(一)
掘金总点赞量前 5000 排行发布 | 掘金总关注量前 5000 排行
掘金站内用户和文章排行分析 | 数据抓取和排序实现
React 源码系列 | React Children 详解 | Children 中 key 内部生成原理
React 源码中的 Object.seal
React 源码系列 | ref 功能详解 | 源码 + 实战例子 | 你可能并不真正懂 ref
React 源码系列-Component、PureComponent、function Component 分析React源码解析-React 导出了些什么
云影sky
关注回复关键词送学习资料,帮你快速掌握刚需技能
关注
您的在看是我创作的动力