3
头图

Preface

The official documentation of TypeScript has long been updated, but the Chinese documents I can find are still in the older version. Therefore, some new and revised chapters have been translated and sorted out.

This article is organized from the " More on Functions " chapter in the TypeScript Handbook.

This article does not strictly follow the original translation, but also explains and supplements part of the content.

text

A function is a fundamental part of any application, whether it is a local function, a function imported from another module, or a method in a class. Of course, functions are also values, and like other values, TypeScript has many ways to describe how functions can be called. Let's learn how to write description function types (types).

Function Type Expressions

The simplest way to describe a function is to use function type expression. is written somewhat like an arrow function:

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

The syntax (a: string) => void indicates that a function has a a type is a string. This function does not return any value.

If the type of a function parameter is not explicitly given, it will be implicitly set to any .

Note that the name of the function parameter is required. This function type description (string) => void means that a function has a parameter of any named string

Of course, we can also use a type alias to define a function type:

type GreetFunction = (a: string) => void;
function greeter(fn: GreetFunction) {
  // ...
}

Call Signatures

In JavaScript, in addition to being called, functions can also have attribute values themselves. However, the function type expression mentioned in the previous section does not support declaring attributes. If we want to describe a function with attributes, we can write a call signature in an object type.

type DescribableFunction = {
  description: string;
  (someArg: number): boolean;
};
function doSomething(fn: DescribableFunction) {
  console.log(fn.description + " returned " + fn(6));
}

Note that this syntax is slightly different from function type expressions. Between the parameter list and the returned type, : is used instead of => .

Construct Signatures

JavaScript functions can also be new operator. When called, TypeScript will consider this to be a constructor because they will generate a new object. You can write a construction signature by adding a new keyword in front of the call signature:

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

Some objects, such as the Date object, can be called directly or by using the new operator, and you can merge the call signature and the construction signature together:

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

Generic Functions

We often need to write this kind of function, that is, the output type of the function depends on the input type of the function, or the types of two inputs are related to each other in some form. Let us consider a function that returns the first element of an array:

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

Note that the type of the return value of the function at this time is any . It would be better if it could return the specific type of the first element.

In TypeScript, generics are used to describe the correspondence between two values. We need to declare the function signature in a type parameter (of the type the Parameter) :

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

By adding a type parameter Type to the function and using it in two places, we create an association between the input of the function (that is, the array) and the output of the function (that is, the return value). Now when we call it, a more specific type will be determined:

// 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

Note that in the above example, we did not explicitly specify Type , the type is automatically inferred by TypeScript.

We can also use multiple type parameters, for example:

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

Note that in this example, TypeScript can not only infer the type of Input (from the string array passed in), but also infer the type Output based on the return value of the function expression.

Constraints

Sometimes, we want to associate two values, but we can only manipulate some fixed fields of the value. In this case, we can use the constraint (constraint) to restrict the type parameters.

Let's write a function that returns the longer of the two values. To this end, we need to ensure that there is a value passed number type of length property. We use extends syntax to constrain function parameters:

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 will infer longest , so the type inference of the return value is also applicable in generic functions.

It is precisely because we have Type a { length: number } restriction on 061d45cdba7fb5, we can be allowed to obtain the .length attribute of the a b Without this type constraint, we can't even get these attributes, because these values may be other types, and there is no length attribute.

Based on the parameters passed in, longerArray and longerString have been inferred. Remember, the so-called generic is to associate two or more values with the same type.

Working with Constrained Values

This is a common error when using generic constraints:

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; }'.
  }
}

This function looks like there is no problem. Type is { length: number} , and the function returns Type or a value that meets the constraint.

The problem is that the function should return an object of the same type as the passed parameter, not just an object that meets the constraints. We can write such a counterexample:

// '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 can usually automatically infer the type parameters passed in in a generic call, but it can't always infer. For example, there is a function that merges two arrays:

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

If you call the function like this, an error will occur:

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

And if you insist on doing this, you can manually specify Type :

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

Some suggestions for writing a good generic function

Although writing generic functions is very interesting, it is also easy to overturn. If you use too many type parameters, or use some unneeded constraints, it may lead to incorrect type inference.

Push Type Parameters Down

The following two functions are written very similarly:

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]);

At first glance, the two functions are too similar, but the first function is written much better than the second function. The first function can infer that the return type is number , but the second function infers the return type is any , because TypeScript has to use the constraint type to infer the arr[0] expression instead of waiting until the function is called to infer this element.

push down in the original text of this section, in "Refactoring", there is an optimization method of Push Down Method, which means that if a function in the super class is only related to one or a few subclasses, Then it's best to remove it from the superclass and put it in the subclasses that really care about it. That is, only the shared behaviors are reserved in the super class. This method of copying the function ontology in the superclass to the subclass that is specifically needed can be called "push down", which is similar to push down extend any[] this section and giving its specific inference to Type itself.

Rule: If possible, use the type parameter directly instead of constraining it

Use Fewer Type Parameters

Here is another pair of functions that look very similar:

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

We created a type parameter Func because it means that the caller has to manually specify an additional type parameter for no reason. Func did nothing but made the function more difficult to read and infer.

Rule: Use as few type parameters as possible

Type parameters should appear twice (Type Parameters Should Appear Twice)

Sometimes we forget that a function does not need generics

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

In fact, we can write this function so simply:

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

Remember: Type parameters are used to associate types between multiple values. If a type parameter appears only once in the function signature, then it is not associated with anything.

Rule: If a type parameter only appears in one place, it is strongly recommended that you reconsider whether you really need it

Optional Parameters

Function in JavaScript is often passed a non-fixed number of parameters, for example: number the toFixed method to support an optional parameter passed:

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

We can use ? indicate that this parameter is optional:

function f(x?: number) {
  // ...
}
f(); // OK
f(10); // OK

Although this parameter is declared as number type, x actual type number | undefiend , because this is not specified in the JavaScript function parameters will be assigned undefined .

Of course, you can also provide a parameter default value:

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

Now f function body, x type of number , because any undefined parameters are replaced with 10 . Note that when a parameter is optional, you can still pass in undefined when calling:

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

Optional Parameters in Callbacks

After you have studied optional parameters and function type expressions, you can easily make the following mistakes in functions that include callback functions:

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

With index? as an optional parameter, the intention is to hope that the following calls are legal:

myForEach([1, 2, 3], (a) => console.log(a));
myForEach([1, 2, 3], (a, i) => console.log(a, i));

But TypeScript does not think so. TypeScript thinks that what it wants to express is that the callback function may only be passed in one parameter. In other words, the myForEach function may also be like this:

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 will understand this meaning and report an error, although this error is actually impossible:

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

How to modify it? In fact, it is possible to not set it as an optional parameter:

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

myForEach([1, 2, 3], (a, i) => {
  console.log(a);
});

In JavaScript, if you call a function and pass in more parameters than needed, the extra parameters will be ignored. TypeScript does the same thing.

When you write the type of a callback function, do not write an optional parameter, unless you really plan to call the function without passing in the actual parameter

Function Overloads

Some JavaScript functions can be called with different numbers and types of parameters. for example. You can write a function that returns a date type Date , this function accepts a timestamp (one parameter) or a month/day/year format (three parameters).

In TypeScript, we can describe different calling methods of a function by writing overlaod signatures. We need to write some function signatures (usually two or more), and then write the content of the function body:

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.

In this example, we wrote two function overloads, one accepting one parameter and the other accepting three parameters. The first two function signatures are called overload signatures.

Then, we wrote a signature-compatible function implementation, which we call implementation signature, but this signature cannot be called directly. Although we declare two optional parameters after a mandatory parameter in the function declaration, it still cannot be called by passing in two parameters.

Overload Signatures and the Implementation Signature (Overload Signatures and the Implementation Signature)

This is a common confusion. People often write code like this, but don't understand why they report an error:

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

Again, the signature written into the function body is "invisible" to the outside, which means that the outside world "cannot see" its signature, and naturally cannot be called in the way that the signature is implemented.

The implementation signature is invisible to the outside world. When writing an overloaded function, you should always need two or more signatures on top of the implementation signature.

And the implementation signature must be compatible with the overload signature. For example, the reason why these functions report an error is because their implementation signature does not match the overload signature correctly.

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";
}

Some suggestions for writing a good function overload

Just like generics, there are some suggestions for you. Following these principles can make your functions easier to call and understand.

Let us imagine a function that returns the length of an array or string:

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

This function code function is implemented, and there is no error, but we can't pass in a value that may be a string or an array, because TypeScript can only handle one function call with one function overload at a time.

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[]'.

Because both function overloads have the same number of parameters and the same return type, we can write a non-overloaded version of the function instead:

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

In this way, the function can be passed in either of the two types.

Use union types as much as possible instead of overloading

Declare this in the function (Declaring this in a Function)

this in the function through the code flow, for example:

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

TypeScript able to understand the function user.becomeAdmin in this points to the outer layer of the object user , which has to cope with many situations, but there are some situations you need to explicitly tell TypeScript this what is in the end it represents.

In JavaScript, this is a reserved word, so it cannot be used as a parameter. But TypeScript allows you to declare the type of this

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

This writing method is somewhat similar to the callback style API. Note that you need to use function instead of arrow functions:

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

Here are some types that will often appear. Like other types, you can use them anywhere, but they are often used in conjunction with functions.

void

void indicates that a function does not return any value. This type should be used when the function does not return any value or cannot return a clear value.

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

In JavaScript, a function does not return any value, it implicitly returns undefined , but void and undefined are different in TypeScript. There will be a more detailed introduction at the end of this article.

void is not the same as undefined

object

This special type object can represent any value that is not primitive ( string , number , bigint , boolean , symbol , null , undefined ). object different from the empty object type { } , and also different from the global type Object . Object you won't use 061d45cdba87d9 either.

object is different from Object , always use object !

Note that in JavaScript, functions are objects, they can have attributes, and there are Object.prototype and instanceof Object in their prototype chain. You can use Object.keys and so on for functions. For these reasons, the function is also considered object in TypeScript.

unknown

unknown type can represent any value. A bit similar to any , but more secure, because it is illegal to do anything with a value of type unknown

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

Sometimes it's quite useful to describe the function type. You can describe a function that can accept any value, but the value of type any

You can describe a function that returns a value of unknown type, such as:

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

never

Some functions never return a value:

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

never type indicates that a value will no longer be observed (observed).

As a return type, it means that this function will throw an exception or end the execution of the program.

When TypeScript determines that there is no possible type in the union type, the never type will also appear:

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

In JavaScript, the global type Function describes bind , call , apply , and all other function values.

It also has a special property, that is Function can always be called, and the result will return type any

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

This is an untyped function call, which is best avoided because it returns an unsafe type any

If you are going to accept a black box function, but don't plan to call it, () => void will be safer.

Rest Parameters and Arguments

parameters and arguments

arguments and parameters can represent the parameters of the function. Because of the specific distinction in this section, we define parameters represent the name set when we define the function, which is the formal parameter, and arguments represent the parameter that we actually pass in the function, which is the actual parameter.

Rest Parameters

In addition to using optional parameters and overloading to allow the function to receive a different number of function parameters, we can also define a function that can pass in an unlimited number of function parameters by using the rest parameters syntax (rest parameters):

The remaining parameters must be placed at the end of all parameters, and use the ... syntax:

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

In TypeScript, the type of the remaining parameters will be implicitly set to any[] instead of any . If you want to set a specific type, it must be in Array<T> or T[] , or the tuple type.

Rest Arguments

We can use an ... syntax to provide a variable number of actual parameters to the function. For example, the push method of the array can accept any number of arguments:

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

Note that in general, TypeScript does not assume that arrays are immutable, which can lead to some unexpected behaviors:

// 类型被推断为 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.

Fixing this problem requires you to write a little code. Generally speaking, using as const is the most direct and effective solution:

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

as const it into a read-only tuple through the 061d45cdba8abe syntax.

Note that when you want to run in an older environment, using the remaining parameter syntax may require you to turn on [downlevelIteration](https://www.typescriptlang.org/tsconfig#downlevelIteration) to convert the code to the old version of JavaScript.

Parameter Destructuring

You can use parameter deconstruction to conveniently deconstruct the object provided as a parameter into one or more local variables in the function body. In JavaScript, it looks like this:

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

After deconstructing the grammar, write the type annotation of the object:

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

This seems a bit cumbersome, you can also write like this:

// 跟上面是有一样的
type ABC = { a: number; b: number; c: number };
function sum({ a, b, c }: ABC) {
  console.log(a + b + c);
}

Assignability of Functions (Assignability of Functions)

Returns void

The function has a void , which will produce some unexpected and reasonable behavior.

When Contextual Typing deduces the return type as void , it does not force the function to not return content. In other words, if such a return void type of function type (type vf = () => void) ,
When applied, it can return any value, but the returned value will be ignored.

Therefore, the following () => void type are all valid:

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

And even if the return values of these functions are assigned to other variables, they will maintain the void type:

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

It is precisely because of this feature that the following code will be effective:

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

Although Array.prototype.push returns a number, and the Array.prototype.forEach method expects a void , this code still reports no error. It is because based on the contextual deduction that the return type of the forEach function is deduced to be void. It is precisely because the function is not mandatory that the function must not return content, so the above return dst.push(el) will not report an error.

In addition, there is a special example that needs to be noted. When a function literal definition returns a void , the function must not return anything:

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

TypeScript series

The TypeScript series of articles consists of three parts: official document translation, important and difficult analysis, and practical skills. It covers entry, advanced, and actual combat. It aims to provide you with a systematic learning TS tutorial. The entire series is expected to be about 40 articles. Click here to browse the full series of articles, and suggest to bookmark the site by the way.

WeChat: "mqyqingfeng", add me to the only reader group in Kongyu.

If there are mistakes or not rigorous, please correct me, thank you very much. If you like or have some inspiration, star is welcome, which is also an encouragement to the author.


冴羽
9.3k 声望6.3k 粉丝

17 年开始写前端文章,至今 6 个系列,上百篇文章,全网千万阅读