More on Functions of TypeScript

Posted by amites on Tue, 16 Nov 2021 11:50:37 +0100

preface

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

This article is compiled from "TypeScript Handbook" More on Functions "Chapter.

This article is not translated strictly according to the original text, and some contents are explained and supplemented.

text

Function is the basic component of any application, whether it is a local function, a function imported from other modules, 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 types that describe functions.

Function Type Expressions

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

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

Syntax (A: String) = > void means that a function has a parameter named a and the type is string. This function does not return any value.

If the type of a function parameter is not explicitly given, it is 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 type any and named string.

Of course, we can also use 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. 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 the function type expression. Instead of = >, it is used between the parameter list and the returned type.

Construct Signatures

JavaScript functions can also be called using the new operator. When called, TypeScript will think that this is a constructor because they will generate a new object. You can write a construction signature by adding a new keyword before the call signature:

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

Some objects, such as Date objects, can be called directly or by using the new operator. You can combine the call signature with the construction signature:

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 two input types are related to each other in some form. Let's consider a function that returns the first element of the array:

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

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

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

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 function's input (i.e. array) and the function's output (i.e. 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 the Type of Type, which 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 infer the type of Input (from the incoming string array) and the type of Output according to the return value of the function expression.

Constraints

Sometimes, we want to associate two values, but we can only operate on some fixed fields of values. In this case, we can use constraint s to limit type parameters.

Let's write a function that returns the longer of the two values. To do this, we need to ensure that the passed in value has a length attribute of type number. We use the 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 infers the return type of longest, so the type inference of return value is also applicable in generic functions.

It is precisely because we have made {length: number} restrictions on Type that we can be allowed to obtain the. Length attribute of a and B parameters. Without this Type constraint, we can't even get these attributes, because these values may be of other types and have no length attribute.

Based on the parameters passed in, the types in longerArray and longerString are inferred. Remember that genericity is the association of 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. The Type is constrained by {length: number}. The function returns Type or a value that conforms to the constraint.

The problem is that the function should return an object of the same type as the passed in parameter, not just an object conforming to the constraint. 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 generic calls, but it can't always infer them. For example, there is a function that combines two arrays:

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

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

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

If you insist on doing so, you can manually specify the Type:

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

Some suggestions for writing a good generic function

Although it's fun to write generic functions, it's also easy to roll over. If you use too many type parameters or use some unnecessary constraints, it may lead to incorrect type inference.

Push Type Parameters Down

The following two functions are written in a similar way:

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 that the return type is any, because TypeScript has to infer the arr[0] expression with the constraint type instead of inferring this element when the function is called.

As for the meaning of 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 superclass is only related to one or a few subclasses, it is best to remove it from the superclass and put it in the subclass that really cares about it. That is, the shared behavior is reserved only in the superclass. This method of copying the function ontology in the superclass to the specific subclass can be called "push down", which is similar to the method of removing extend any [] in this section and handing the specific inference to the Type itself.

Rule: if possible, use type parameters directly instead of constraining it

Use fewer type parameters

This 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 with the no associated two values, which is a red flag because it means that caller has to manually specify an additional type parameter for no reason. Func does nothing, but makes the function more difficult to read and infer.

Rule: use as few type parameters as possible

Type Parameters Should Appear Twice

Sometimes we forget that a function doesn't need generics

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

In fact, we can simply write this function:

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

Functions in JavaScript are often passed in a non fixed number of parameters. For example, the toFixed method of number supports passing in an optional parameter:

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

Can we use? Indicates that this parameter is optional:

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

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

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

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

Now in the body of the f function, the type of x is number, because any undefined parameter will be 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 learned the optional parameters and function type expressions, you can easily make the following mistakes in the functions containing callback functions:

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

index? As an optional parameter, it is intended that the following calls are legal:

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

However, TypeScript does not think so. TypeScript thinks 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 and report an error according to this meaning, although in fact, this error is impossible:

// Note: there is no error in the latest TypeScript version
myForEach([1, 2, 3], (a, i) => {
  console.log(i.toFixed());
  // Object is possibly 'undefined'.
});

How to modify it? If it is not set as an optional parameter, you can:

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 you need, the additional parameters will be ignored. TypeScript does the same.

When you write the type of a callback function, don't write an optional parameter unless you really want to call the function without passing in an argument

Function Overloads

Some JavaScript functions can pass in different numbers and types of parameters when calling. for instance. You can write a function that returns a Date type Date. This function receives a timestamp (one parameter) or a month / day / year format (three parameters).

In TypeScript, we can specify different calling methods of a function by writing overload 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 write two function overloads. One accepts one parameter and the other accepts three parameters. The first two function signatures are called overload signatures.

Then, we write 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 required parameter in the function declaration, it cannot be called by passing in two parameters.

Overload Signatures and the Implementation Signature

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

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 world, which means that the outside world "can't see" its signature, so it can't be called in the way of implementing the signature.

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.

Moreover, the implementation signature must be compatible with the overload signature. For example, the reason why these functions report an error is that 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's 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 are no errors, but we can't pass in a value that may be a string or an array, because TypeScript can only use one function overload to process a function call 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 function without overload version instead:

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

This allows the function to pass in either of the two types.

Use union types instead of overloads whenever possible

Declaring this in a function

TypeScript will analyze the type of this in the function through code flow. For example:

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

TypeScript can understand that this in the function user.becomeAdmin points to the outer object user, which can cope with many situations, but there are still some situations that need you to clearly tell TypeScript what this 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 in the function body.

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

This is a bit similar to the callback style API. Note that you need to use function instead of arrow 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

Here are some common types. Like other types, you can use them anywhere, but they are often used with functions.

void

void means that a function does not return any value. This type should be used when the function does not return any value or cannot return an explicit value.

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

In JavaScript, a function does not return any value and 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 different from undefined

object

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

Object is different from object. Always use object!

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

unknown

The unknown type can represent any value. It's a bit like any, but it's safer because it's 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 is useful to describe function types. You can describe a function that can accept any value passed in, but you don't need any value of type any in the function body.

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 values:

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

The never type indicates that a value is no longer observed.

As a return type, it means that the 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 properties such as bind, call, apply, and all other Function values.

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

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

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

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

Rest Parameters and Arguments

parameters and arguments

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

Rest Parameters

In addition to using optional parameters and overloads to enable functions to receive different numbers 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:

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 the form of array < T > or T [] or tuple type.

Rest Arguments

We can use an array using the... Syntax to provide an indefinite number of arguments to the function. For example, the push method of an 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 the array is immutable, which will lead to some unexpected behaviors:

// The type is inferred as 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.

You need to write some code to fix this problem. 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);

This problem can be solved by changing it into a read-only tuple through the as const 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 ), convert the code to an older version of JavaScript.

Parameter deconstructing

You can use parameter deconstruction to easily deconstruct the object provided as a parameter into one or more local variables in the function body. In JavaScript, it is as follows:

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

After deconstructing the syntax, 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 little cumbersome. You can also write this:

// It's the same as above
type ABC = { a: number; b: number; c: number };
function sum({ a, b, c }: ABC) {
  console.log(a + b + c);
}

Assignability of Functions

Return void

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

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

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

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

Moreover, even if the return value of these functions is assigned to other variables, the void type will be maintained:

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

It is precisely because of this feature that the following code is 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 function that returns void type, this code still does not report an error. This is because the return type of the forEach function is deduced to be void based on context derivation. It is precisely because the function is not forced to return content, so the above writing of return dst.push(el) will not report an error.

In addition, there is another special example to note that when a function number facet definition returns a void type, 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

Yu Yu's full series address: https://github.com/mqyqingfeng/Blog

TypeScript series is a series of articles that I don't know what to write. If you have any confusion or want to know what TypeScript is about, please contact me. WeChat: mqyqingfeng, official account: JavaScript blog or yayujs

If there is any mistake or lack of preciseness, please be sure to correct it. Thank you very much. If you like it or have some inspiration, welcome star, which is also an encouragement to the author.

Topics: Javascript Front-end ECMAScript html5 TypeScript