TypeScript之函数类型进阶

383 阅读17分钟

函数(funciton)是应用的基础模块,无论它们是自身的函数,还是从其它模块导入的函数,又或者是在类(class)中的函数。函数也是值,和其它值一样,TypeScript也有很多种方式去描述函数的调用。接下来让我们去学习怎么用类型来描述函数

函数类型表达式(Function Type Expressions)

最简单的方式去描述一个函数是使用函数类型表达式(Function Type Expressions)函数类型表达式的语法类似箭头函数(arrow functions):

function greeter(fn: (a: string) => void) {
  fn("Hello, World");
}
 
function printToConsole(s: string) {
  console.log(s);
}
 
greeter(printToConsole);

语法(a:string) => void的含义是“有一个类型为string的a参数的函数,并且没有任何返回值”。和函数的声明一样,如果参数的类型没有指定,那参数的类型会被隐式推断为any

请注意参数的名称是不能缺的。函数类型(string) => void代表“有一个类型为any的string参数的函数”

当然,我们可以使用类型别名来命名一个函数类型

type GreetFunction = (a: string) => void; // 类型别名

function greeter(fn: GreetFunction) {
  // ...
}

调用签名(Call Signatures)

JavaScript中,函数除了可调用之外还可以具有属性。实际上,函数类型表达式语法不允许定义属性。如果我们想要描述在函数上的属性,那我们可以在对象类型中写一个调用签名(call signature):

type DescribableFunction = {
  description: string;
  (someArg: number): boolean; // 调用签名
};

function doSomething(fn: DescribableFunction) {
  console.log(fn.description + " returned " + fn(6));
}

请注意语法和函数类型表达式并不同----在参数列表和返回类型之间使用:而不是=>

构造函数签名(Construct Signatures)

JavaScript函数可以被new操作符调用。TypeScript将这些称为构造函数,因为它们通常会创建一个新对象。你可以通过在调用签名添前添加一个new关键字写一个构造签名:

type SomeConstructor = {
  new (s: string): SomeObject;
};
function fn(ctor: SomeConstructor) {
  return new ctor("hello");
}

在一些对象中,例如JavaScriptDate对象,可以在不使用new的情况下调用。你可以任意地在同一个类型组合调用签名构造签名

interface CallOrConstruct {
  new (s: string): Date;
  (n?: number): number;
}

泛型函数(Generic Functions)

编写一个输入类型输出类型相关的函数是很常见的,或者输入输出的类型以某种方式关联。让我们考虑一个常用的函数,这个函数用来返回数组的第一个元素:

function firstElement(arr: any[]) {
  return arr[0];
}

这个函数完成了它的工作,但是不幸的是返回值any。最好的是,这个函数能返回这个数组元素的类型。 在TypeScript中,泛型(generics)可以被用作来描述两个值之间的对应关系。我们使用这个来描述这个函数的类型参数:

function firstElement<Type>(arr: Type[]): Type | undefined {
  return arr[0];
}

通过添加一个类型参数Type到这个函数并且把它放在两个地方,我们在输入输出之间创建了一个链接。现在当我们调用它的时候,更多的指定类型会被返回:

// s is of type 'string'
const s = firstElement(["a", "b", "c"]);
// n is of type 'number'
const n = firstElement([1, 2, 3]);
// u is of type undefined
const u = firstElement([]);

泛型推导(Inference)

请注意我们没有指定Type在这个例子中。Type的类型是被TypeScript自动推导的,自动选择的。我们也可以使用多个type参数。比如说,一个单独版本的map是这样的:

function map<Input, Output>(arr: Input[], func: (arg: Input) => Output): Output[] {
  return arr.map(func);
}
 
// Parameter 'n' is of type 'string'
// 'parsed' is of type 'number[]'
const parsed = map(["1", "2", "3"], (n) => parseInt(n));

请仔细看这个例子,TypeScript可以推断出Input参数的类型(从提供的string数组中推断出来),以及类型参数 Output也从函数表达式的返回值(number)中推导了出来。

泛型约束(Constraints)

我们写的泛型函数可以接受任何值。但是有些是由我们想在两个值之间建立联系,但只能对值的某个子集进行操作。在这种情况下,我们可以使用一个泛型约束限制参数接收类型的种类。 让我们编写可以一个返回两个字符串中比较长的函数。为了做到这个,我们需要有一个number类型的length属性。我们通过一个extends来限制这个类型参数:

function longest<Type extends { length: number }>(a: Type, b: Type) {
  if (a.length >= b.length) {
    return a;
  } else {
    return b;
  }
}
 
// longerArray is of type 'number[]'
const longerArray = longest([1, 2], [1, 2, 3]);
// longerString is of type 'alice' | 'bob'
const longerString = longest("alice", "bob");
// Error! Numbers don't have a 'length' property
const notOK = longest(10, 100);
// Argument of type 'number' is not assignable to parameter of type '{ length: // number; }

在这个例子中可以看到有一些有意思的事情。我们允许TypeScript自动推导longst的返回类型。类型推导也可以在泛型的函数中工作。 因为我们约束Type{ length : number },所以我们可以访问参数a和参数blength属性。如果没有泛型约束,我们将没有能力访问这些属性,因为参数可能是其它的没有length属性的值。

longerArraylongerString的类型基于传递参数(arguments)被推断出来。请记住,泛型的作用就是约束两个或者更多的值成为同样类型的值。

最后,我们看到了我们想要的,调用longest(10, 100)被拒绝了,因为number类型没有length属性。

用泛型来约束值(Working with Contrained Values)

function minimumLength<Type extends { length: number }>(
  obj: Type,
  minimum: number
): Type {
  if (obj.length >= minimum) {
    return obj;
  } else {
    return { length: minimum }; 
    //返回值的类型和传参的类型是一样的,所以这么返回会报错
 // Type '{ length: number; }' is not assignable to type 'Type'.
 // '{ length: number; }' is assignable to the constraint of type 'Type', but  'Type' could be instantiated with a different subtype of constraint '{ length: number; }'.
  }
}

这个函数或许看起来是没有什么问题的--Type类型被{ length: number }约束了,并且函数的返回类型也是Type,或者说返回值也被约束了。但是有个问题,函数承诺返回的值传参同一个类型的对象,而不是一些符合约束类型的对象。如果这段代码是合法的,那可能会存在很多问题:

// 'arr' gets value { length: 6 }
const arr = minimumLength([1, 2, 3], 6);
// and crashes here because arrays have
// a 'slice' method, but not the returned object!
console.log(arr.slice(0));

指定参数类型(Specifying Type Arguments)

TypeScript通常可以在泛型调用中推断出类型参数,但不是一直可以。看一个例子,写一个合并两个数组的函数:

function combine<Type>(arr1: Type[], arr2: Type[]): Type[] {
  return arr1.concat(arr2);
}

正常来说,传递一个不匹配的数组是会报错误的:

const arr = combine([1, 2, 3], ["hello"]);
// Type 'string' is not assignable to type 'number'.

如果你想刻意的达到这种效果,那你就需要给Type指定一个类型:

const arr = combine<string | number>([1, 2, 3], ["hello"]);

泛型函数指南(Guidelines for Writing Good Generic Functions)

写泛型函数是非常有趣的,并且类型参数是很容易让人着迷的。类型参数过多或者在不需要的地方使用泛型,有时并不能成功的推断出类型。

能用类型参数就用类型参数(Push Type Parameters Down)

这里有两种看起来很相似方式去写函数:

function firstElement1<Type>(arr: Type[]) {
  return arr[0];
}
 
function firstElement2<Type extends any[]>(arr: Type) {
  return arr[0];
}
 
// a: number (good)
const a = firstElement1([1, 2, 3]);
// b: any (bad)
const b = firstElement2([1, 2, 3]);

乍一看,这两个似乎是一样的。但是firstElement1用了一种更好的方式写函数。它推断出了Type,但是firstElement2推断出返回类型是any类型。因为TypeScript解释arr[0]表达式用了泛型约束,从而不再等到函数被调用时再解释元素类型。

Rule:尽量使用类型参数(type parameter)而不是用泛型约束去约束它

用更少的类型参数(Use Fewer Type Parameters)

这里有另一对相似的函数:

function filter1<Type>(arr: Type[], func: (arg: Type) => boolean): Type[] {
  return arr.filter(func);
}
 
function filter2<Type, Func extends (arg: Type) => boolean>(
  arr: Type[],
  func: Func
): Type[] {
  return arr.filter(func);
}

我们创建了一个没有将两个值联系起来的类型参数(type parameter)Func。这是一个不好的示例,因为没有任何理由添加一个额外的类型。Func没有做任何事情的同时让这个函数变得更加难以理解了。

Rule:尽量少使用类型参数(type parameters)

类型参数应该出现两次(Type Parameters Should Appear Twice)

有些时候我们忘记函数或许不需要泛型

function greet<Str extends string>(s: Str) {
  console.log("Hello, " + s);
}
 
greet("world");

我们其实可以写一个非常简单的版本:

function greet(s: string) {
  console.log("Hello, " + s);
}

请记住,类型参数(type parameters)是用来再多个值之间建立有关联的类型的(relating the types of multiple values)不知道翻译的对不对,谷歌翻译更离谱 。如果类型参数(type parameter)仅仅在函数签名(function signature)中被使用过一次的话,请考虑是否真的需要它。

可选参数(Optional Parameters)

JavaScript中函数经常携带了一个可见的参数列表。举个例子,toFixed函数的传参是可选的:

function f(n: number) {
  console.log(n.toFixed()); // 0 arguments
  console.log(n.toFixed(3)); // 1 argument
}

我们可以通过使用?重构这个函数使得这个参数成为可选:

function f(x?: number) {
           // number | undefined类型
  // ...
}
f(); // OK
f(10); // OK

尽管这个参数被指定为number类型,但是这个x参数实际上仍然会有number | undefined,因为在JavaScript中没有指定的参数将会变成undefined。 你也可以提供一个默认值参数:

function f(x = 10) {
  // ...
}

现在在函数f中,x变为一个number类型的参数,因为任何undefined值的参数将会被10代替。请注意,当一个参数是可选的时候,调用者可以传递undefined,简单的来模拟一下没有传递参数的情况:

declare function f(x?: number): void;
// cut
// All OK
f();
f(10);
f(undefined);

在回调中的可选参数(Optional Parameters in Callbacks)

在你学会了在函数表达式(function type expressions)中使用可选参数的情况下,在函数中使用回调很容易犯下一下错误:

function myForEach(arr: any[], callback: (arg: any, index?: number) => void) {
  for (let i = 0; i < arr.length; i++) {
    callback(arr[i], i);
  }
}

大多数人在编写index?的时候通常打算做的是:作为一个可选参数,他们希望这两个调用都是合法的:

function myForEach(arr: any[], callback: (arg: any, index?: number) => void) {
  for (let i = 0; i < arr.length; i++) {
    // I don't feel like providing the index today
    callback(arr[i]);
  }
}

反过来说,TypeScript将强制提示实际上不可能发生的错误:

myForEach([1, 2, 3], (a, i) => {
  console.log(i.toFixed());
Object is possibly 'undefined'.
});

JavaScript中,如果你调用的参数列表超过了你定义的参数,那额外传递的参数是会被简单的忽略的。TypeScrip的表现和这个一致。参数列表更少的函数可以总是可以代替具有更多参数的函数。

当写一个回调函数的函数类型时,千万不要写可选参数(optional parameter),除非你打算不通过传递参数来调用这个函数。

函数重载(Function Overloads)

有一些JavaScript函数可以被各种参数数量和类型来调用。举个例子,你或许可以写一个函数来制造一个Date,这个函数可以传递时间戳(一个参数)或者传递 month/day/year(三个参数)的格式。

TypeScript中,我们可以通过写重载签名(overload signatures)来定义一个函数的不同调用方式。为了做到这个,首先要写一定数量的函数签名(一般是两个或者更多),紧接着的是函数具体的实现:

function makeDate(timestamp: number): Date;
function makeDate(m: number, d: number, y: number): Date;
function makeDate(mOrTimestamp: number, d?: number, y?: number): Date {
  if (d !== undefined && y !== undefined) {
    return new Date(y, mOrTimestamp, d);
  } else {
    return new Date(mOrTimestamp);
  }
}
const d1 = makeDate(12345678);
const d2 = makeDate(5, 5, 5);
const d3 = makeDate(1, 3);
// No overload expects 2 arguments, but overloads do exist that expect either 1 or 3 arguments.

在这个例子当中,我们写了两个重载:一个是接受一个参数,另一个接受三个参数。 这两个签名被称为重载签名(overload signatures)。 之后,我们写了一个兼容上述签名的函数。函数有一个实现签名(implementation signature),但是这个签名不可以被直接调用。即使我们在必选的参数之后编写了一个带有两个可选参数的函数,也不能通过那两个参数来调用它。

重载签名和实现签名(Overload Signatures and the Implementation Signature)

这是两个非常容易混淆的概念。我们经常像这样写代码但是不明白这里会什么会有一个错误:

function fn(x: string): void;
function fn() {
  // ...
}
// Expected to be able to call with zero arguments
fn();
// Expected 1 arguments, but got 0.

再提一次,这个用来写签名的函数体不能被TypeScript发现。

实现签名(The signature of implementation)对于TypeScript来说不能被显式的调用。当写了一个重载的函数,对于函数实现签名(implementation of the function),应该有两个或者更多兼容性签名 。 这个实现签名(implementation signature)必须兼容重载签名(overload signatures)。举个例子,以下函数因为没有正确匹配重载而发生了错误:

function fn(x: boolean): void;
// Argument type isn't right
function fn(x: string): void;
// This overload signature is not compatible with its implementation signature.
function fn(x: boolean) {}
function fn(x: string): string;
// Return type isn't right
function fn(x: number): boolean;
// This overload signature is not compatible with its implementation signature.
function fn(x: string | number) {
  return "oops";
}

写一个良好的重载(Writing Good Overloads)

像泛型一样,在使用函数重载的时候,这里有一些指南你应该遵循。遵循这个原则会让你的函数变得调用起来更加简单,更容易理解,和更容易实现。

考虑一个返回数组或者字符串长度的函数:

function len(s: string): number;
function len(arr: any[]): number;
function len(x: any) {
  return x.length;
}

这个函数工作得很好;我们可以通过传递字符串或者数组来调用它。但是实际上,我们不能传给它一个string | number[]类型的值,因为TypeScript不能找到这个重载:

len(""); // OK
len([0]); // OK
len(Math.random() > 0.5 ? "hello" : [0]);
 //No overload matches this call.
 // Overload 1 of 2, '(s: string): number', gave the following error.
 // Argument of type 'number[] | "hello"' is not assignable to parameter of // type 'string'.
 //      Type 'number[]' is not assignable to type 'string'.
 // Overload 2 of 2, '(arr: any[]): number', gave the following error.
 //   Argument of type 'number[] | "hello"' is not assignable to parameter of type 'any[]'.
 //Type 'string' is not assignable to type 'any[]'.

因为两个重载都有相同数量的传参和相同的返回类型,我们可以写一个不是重载版本的函数来代替:

function len(x: any[] | string) {
  return x.length;
}

这更好!调用者可以调用其中任何一个值,并且其它来说,我们不需要清楚正确的实现签名:

尽可能使用联合类型的参数代替重载

在函数中定义this(Declaring this in a Function)

在函数控制流程分析中,TypeScript将推断this的类型:

const user = {
  id: 123,
 
  admin: false,
  becomeAdmin: function () {
    this.admin = true;
  },
};

TypeScript明白user.becomeAdmin对应的this是外面的user,这样在很多情况下都足够了,但是很多时候你需要控制对象的this。在JavaScript的特殊状态中,并不能声明一个叫this的函数,所以TypeScript使用一个语法来让你允许你在函数体内定义this的类型。

interface DB {
  filterUsers(filter: (this: User) => boolean): User[];
}
 
const db = getDB();
const admins = db.filterUsers(function (this: User) {
  return this.admin;
});

这个和回调函数非常相似。请注意你需要使用function关键字来声明函数,不能用箭头函数来实现这个行为:

interface DB {
  filterUsers(filter: (this: User) => boolean): User[];
}
 
const db = getDB();
const admins = db.filterUsers(() => this.admin);
// The containing arrow function captures the global value of 'this'.
// Element implicitly has an 'any' type because type 'typeof globalThis' has no index signature.

其它需要知道的类型(Other Types to Know About)

这些是在工作当中经常会出现的类型。就像所有的类型一样,你可以在任何地方使用它们,但是这和上下文有关。

void

void代表函数没有返回值。在函数没有return语法时,都会被推断为void,或者在return后面不返回任何值:

// The inferred return type is void
function noop() {
  return;
}

JavaScript中,函数没有任何返回值会被隐式的返回undefined。然而,在TypeScript中,voidundefined不是同一件事。

voidundefined是不同的

object

object被用来表示不是任何原始值(string,number,bigint,boolean,symbol,null,undefined)object空对象{}(empty object tpye)是不同的,同时和全局类型Object也是不同的。虽然看起来你从来不用Object

object 不是 Object。请使用object 注意在JavaScript中,函数是对象:函数有属性,有Object.prototype在原型链上,可以在函数上使用instanceof ObjectObject.keys。基于这个理由,TypeScript函数类型定义为object类型。

unknow

unknow类型代表任何值。这个值类似any类型,但是它是类型安全的,unknow值并不能做任何事:


function f1(a: any) {
  a.b(); // OK
}
function f2(a: unknown) {
  a.b();
// Object is of type 'unknown'.
}

在定义函数类型时,这非常有用,你可以不再使用any来定义。 反过来,你可以描述函数返回unknow值:

function safeParse(s: string): unknown {
  return JSON.parse(s);
}
 
// Need to be careful with 'obj'!
const obj = safeParse(someRandomString);

never

有些函数永远不会返回值:

function fail(msg: string): never {
  throw new Error(msg);
}

never类型表示值不会被观察到。在一个返回值类型当中,这代表在项目中,函数抛出了一个错误或者终端错误never也会出现在确定联合类型被推导完了之后:

function fn(x: string | number) {
  if (typeof x === "string") {
    // do something
  } else if (typeof x === "number") {
    // do something else
  } else {
    x; // has type 'never'!
  }
}

Function

全局类型Function描述bindcallapply属性,和在JavaScript函数的其它属性。Function类型也可以指定属性可以被调用,但是返回值是any

function doSomething(f: Function) {
  return f(1, 2, 3);
}

这是一个无类型函数的调用(untyped function call),最好不要这么使用,因为返回值是不安全类型any。 如果你需要接受一个函数,但是不打算去调用它,那么()=>void通常是最安全的。

剩余形式参数和剩余传递参数(Rest Parameters and Arguments)

剩余形式参数(Rest Parameters)

此外用可选参数(optional parameters)或者重载来让函数可以接收多种固定参数,我们可以使用剩余参数(rest parameters)定义一个没有边界的函数。 剩余参数放在所有其它所有参数的后面,使用...语法:

function multiply(n: number, ...m: number[]) {
  return m.map((x) => n * x);
}
// 'a' gets value [10, 20, 30, 40]
const a = multiply(10, 1, 2, 3, 4);

TypeScript中,剩余参数的类型注释隐式的被推断为any[],并且类型参数必须被赋予是Array<T>或者T[],或者一个元组(tuple)

剩余传实际参数(Arguments)

相反的,我们可以使用...语法从数组中提供可变数量的参数。举个例子,push方法可以传递任何数量的传递参数。

const arr1 = [1, 2, 3];
const arr2 = [4, 5, 6];
arr1.push(...arr2);

请注意,TypeScript不会推断数组是不可变的。这可以导致很多问题:

// Inferred type is number[] -- "an array with zero or more numbers",
// not specifically two numbers
const args = [8, 5];
const angle = Math.atan2(...args); //只有两个传参,但是传了一个数组
// A spread argument must either have a tuple type or be passed to a rest // parameter.

最好的方案是使用const关键字:

// Inferred as 2-length tuple
const args = [8, 5] as const;
// OK
const angle = Math.atan2(...args);

参数解构(Parameter Destructuring)

你可以使用参数解构来方便的将参数提供的对象结构为函数体的变量。在JavaScript中,它看起来是这样的:

function sum({ a, b, c }: { a: number; b: number; c: number }) {
  console.log(a + b + c);
}

这看起来有点冗长,但是你可以重新命名一个类型:

// Same as prior example
type ABC = { a: number; b: number; c: number };
function sum({ a, b, c }: ABC) {
  console.log(a + b + c);
}

函数的可变性

返回类型void

void返回类型对于函数来说产生了一些不同寻常的但是在预期之中的行为。返回类型为void的上下文类型不会强制函数不返回其它类型。 换个说法,这个上下文函数类型void(vf=()=>void),实现时可以返回任何其它值。

因此,以下类型()=>void的实现时有效的:

type voidFunc = () => void;
 
const f1: voidFunc = () => {
  return true;
};
 
const f2: voidFunc = () => true;
 
const f3: voidFunc = function () {
  return true;
};

并且当其中一个函数的返回值被赋予另一个变量时,它会保留void类型:

const v1 = f1();
 
const v2 = f2();
 
const v3 = f3();

这种情况,在Array.prototype.push返回一个number类型并且Array.prototype.forEach方法期望函数返回一个void也会出现:

const src = [1, 2, 3];
const dst = [0];
 
src.forEach((el) => dst.push(el));

存在另一种特殊请款,当一个字面量函数(literal function)被定义了一个void的返回类型,那这个方法必须不能返回任何东西:

function f2(): void {
  // @ts-expect-error
  return true;
}
 
const f3 = function (): void {
  // @ts-expect-error
  return true;
};

参考More on Functions