1. Preface

TypeScript introduced two basic types, “never” and “unknown”, in version 2.0 and 3.0 respectively. The type system of TypeScript has been greatly improved.

However, when I take over the code, I find that many people are still stuck in the era of 1.0, the era of using any everywhere. After all, JavaScript is a weakly typed dynamic language, and we used to not spend much time on type design. After the introduction of TypeScript, we even complained, “Why is this code getting more and more complicated?”.

In fact, we should think the other way around: an OOP programming paradigm is what code should look like after ES6.

2. top type, bottom type in TypeScript

In the type system design, there are two special types.

  • Top type: known as the generic parent type, which is the type that can contain all values.
  • Bottom type: represents a type that has no values, it is also called zero or empty type and is a subtype of all types.

In TypeScript 3.0, there are two top types (any and unknown) and one bottom type (never), as explained in the type system.

top type, bottom type in TypeScript

But some people think that any is also a bottom type, because any can also be a subtype of many types. But this argument is not strict, we can take a deeper look at unknown, any and never types.

3. unknown and any

3.1 unknown – for anything

When I read my colleagues’ code, I rarely see the unknown type appear. This does not mean that it is unimportant; on the contrary, it is a safe version of any type.

The difference between it and any is simple, see the following example.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
function format1(value: any) {
    value.toFixed(2); // 不飘红,想干什么干什么,very dangerous
}

function format2(value: unknown) {
    value.toFixed(2); // 代码会飘红,阻止你这么做

    // 你需要收窄类型范围,例如:

    // 1、类型断言 —— 不飘红,但执行时可能错误
    (value as Number).toFixed(2);

    // 2、类型守卫 —— 不飘红,且确保正常执行
    if (typeof value === 'number') {
        // 推断出类型: number
        value.toFixed(2);
    }

    // 3、类型断言函数,抛出错误 —— 不飘红,且确保正常执行
    assertIsNumber(value);
    value.toFixed(2);
}


/** 类型断言函数,抛出错误 */
function assertIsNumber(arg: unknown): asserts arg is Number {
    if (!(arg instanceof Number)) {
        thrownewTypeError('Not a Number: ' + arg);
    }
}

Using any is like exploring a haunted house, the code is executed with ghosts everywhere. The combination of unknown with type guards, for example, ensures that code executes properly even when the upstream data structure is uncertain.

3.2 any

By using any, we’re giving up type checking, because it’s not used to describe specific types.

Before using it, we need to think of two things.

  1. whether it is possible to use a more specific type
  2. whether unknown can be used instead

If neither is possible, then any is the last option.

3.3 Reviewing previous type designs

Some existing type designs use any, which is not accurate enough. Here are two examples.

3.3.1 String()

String() can take any argument and convert it to a string.

Combined with the unknown type introduced above, the arguments here can actually be designed as unknown, but the internal implementation will need more type guards. But the unknown type came later, so the initial design still uses any, which is what we see now.

1
2
3
4
5
6
7
8
9
/**
 * typescript/lib/lib.es5.d.ts
 */
interface StringConstructor {
    new(value?: any): String;
    (value?: any): string;
    readonly prototype: String;
    fromCharCode(...codes: number[]): string;
}

3.3.2 JSON.parse()

I recently wrote a piece of code that involves deep copy.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
exportfunction deleteCommentFromComments<T>(comments: GenericsComment<T>[], comment: GenericsComment<T>) {
  // 深拷贝
  const list: GenericsComment<T>[] = JSON.parse(JSON.stringify(comments));

  // 找到对应的评论下标
  const targetIndex = list.findIndex((item) => {
    if (item.comment_id === comment.comment_id) {
      returntrue;
    }
    returnfalse;
  });

  if (targetIndex !== -1) {
    // 剔除对应的评论
    list.splice(targetIndex, 1);
  }

  return list;
}

Obviously, the output of JSON.parse() changes dynamically with the input (and may even throw an Error), and its function signature is designed as follows.

1
2
3
4
interface JSON {
    parse(text: string, reviver?: (this: any, key: string, value: any) =>any): any;
    ...
}

Can I use unknown here? Yes, but for the same reason as above, the function signature of JSON.parse() was added to the TypeScript system before the unknown type appeared, otherwise its return type should be unknown.

4, never

mentioned above, the never type is the empty type, that is, the value never exists type.

Value will never exist in two cases.

  1. if a function is executed when the exception is thrown, then the function will never exist return value (because the exception will be thrown to directly interrupt the program, which makes the program does not run to return the value of that step, that is, with the end of the unreachable, there will never be a return).
  2. the function executes an infinite loop of code (dead loop), so that the program can never run to the return value of the function, there is never a return.
1
2
3
4
5
6
7
8
9
// 异常
function err(msg: string): never { // OK
  throw new Error(msg); 
}

// 死循环
function loopForever(): never { // OK
  while (true) {};
}

4.1 The only bottom type

Since never is the only bottom type of typescript, it can represent a subtype of any type, so it can be assigned to any type.

1
2
3
4
let err: never;
let num: number = 4;

num = err; // OK

only bottom type

We can use the set to understand never, unknown is the full set, never is the smallest unit (empty set), and any type contains never.

4.1.1 null/undefined and never

Here you may ask, null and undefined seem to be subtypes of any type, so why not bottom type. never is special in that no type is a subtype of it or can be assigned to it except itself. We can use the following example to compare.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// null 和 undefined,可以被 never 赋值
declare const n: never;

let a: null = n; // 正确
let b: undefined = n; // 正确

// never 是 bottom type,除了自己以外没有任何类型可以赋值给它
let ne: never;

ne = null; // 错误
ne = undefined; // 错误

declare const an: any;
ne = an; // 错误,any 也不可以

declareconst nev: never;
ne = nev; // 正确,只有 never 可以赋值给 never

The above example basically illustrates the difference between null/undefined and never, with never being the bottom one.

4.1.2 Why any is not a strict bottom type

When I read some articles, I found that people often say that any is both a top type and a bottom type, but this is not a strict statement.

We know from the above that no type can be assigned to never except never itself. does any satisfy this property? Obviously not, for a very simple example.

1
2
3
4
const a = 'anything';

const b: any = a; // 能够赋值
const c: never = a; // 报错,不能赋值

And why do we say never is the bottom type? Wikipedia explains it this way.

A function whose return type is bottom (presumably) cannot return any value, not even the zero size unit type. Therefore a function whose return type is the bottom type cannot return.

It is also easy to see from this that in a type system, bottom type is unique in that it uniquely describes the case of a function that returns nothing. So, with never, any heresy that is not type-checked is definitely not a bottom type.

4.2 The beauty of never

Never has the following usage scenarios.

  • Unreachable code checking: mark unreachable code and get compilation hints.
  • Type operation: as a minimal factor in type operations.
  • Exhaustive Check: Create compilation hints for compound types.
  • ……

There is no denying that never is a wonderful thing. From a set theory perspective, it is an empty set, so it can bring a lot of convenience to our type work through some properties of empty sets. Next, let’s talk about each usage scenario in detail.

4.2.1 Unreachable code check

A beginner wrote the following line of code.

1
2
process.exit(0);
console.log("hello world") // Unreachable code detected.ts(7027)

Don’t laugh, it’s a real possibility. Of course at this point if you use ts, it will give you a compiler hint.

1
Error: Unreachable code detected.ts(7027)

Because the return type of process.exit() is defined as never, what comes after it is naturally the “unreachable code”.

Other possible scenarios are listening to sockets.

1
2
3
4
5
6
7
8
function listen(): never {
  while(true){
    let conn = server.accept();
  }
}

listen();
console.log("!!!"); // Unreachable code detected.ts(7027)

In general, we manually mark the return value of a function as never to help the compiler recognize “unreachable code” and to help us narrow (narrow) the type. Here is an example of unmarking.

1
2
3
4
5
6
7
8
9
function throwError() {
  throw new Error();
}

function firstChar(msg: string | undefined) {
  if (msg === undefined)
    throwError();
  let chr = msg.charAt(1) // Object is possibly 'undefined'.
}

Since the compiler does not know that throwError is a no-return function, the code after throwError() is assumed to be reachable in any case, leaving the compiler with the misconception that the type of msg is string | undefined.

If the never type is marked, then the type of msg will be narrowed to string after the null check.

1
2
3
4
5
6
7
8
9
function throwError(): never {
  throw new Error();
}

function firstChar(msg: string | undefined) {
  if (msg === undefined)
    throw Error();
  let chr = msg.charAt(1) // ✅
}

4.2.2 Type operations

4.2.2.1 Minimal factors

As mentioned above never can be understood as an empty set, then it will satisfy the following operation rules.

1
2
T | never => T
T & never =>never

That is, never is the smallest factor of a type operation. These rules help us simplify some trivial type operations, for example, multiple Promises like Promise.race merging, where sometimes it is impossible to know exactly the timing and return result. Now we use a Promise.race to merge a Promise that has a network request return value with another Promise that will be rejected within a given time.

1
2
3
4
5
6
7
asyncfunction fetchNameWithTimeout(userId: string): Promise<string> {
  const data = await Promise.race([
    fetchData(userId),
    timeout(3000)
  ])
  return data.userName;
}

The following is an implementation of a timeout function that throws an Error if the specified time is exceeded, and since it returns nothing, the result is defined as Promise<never>.

1
2
3
4
5
function timeout(ms: number): Promise<never> {
  return new Promise((_, reject) => {
    setTimeout(() => reject(newError("Timeout!")), ms)
  })
}

Good, then the compiler will infer the return value of Promise.race, because race takes the result of the first Promise to complete, so in the example above, it has a function signature like this.

1
function race<A, B>(inputs: [Promise<A>, Promise<B>]): Promise<A | B>

Substituting in fetchData and timeout, A would be { userName: string }, while B would be never. Therefore, the function outputs a promise return value of type { userName: string } | never. And since never is the minimum factor, it can be eliminated. So the return value can be reduced to { userName: string }, which is exactly what we want.

What happens if we use any or unknown here?

1
2
3
4
5
6
7
8
9
// 使用 any
function timeout(ms: number): Promise<any> {
  ......
}
// { userName: string } | any => any,失去了类型检查
asyncfunction fetchNameWithTimeout(userId: string): Promise<string> {
  ......
  return data.userName; // ❌ data 被推断为 any
}

any is well understood, it passes normally, but it is equivalent to no type checking.

1
2
3
4
5
6
7
8
9
// 使用 unknown
function timeout(ms: number): Promise<unknown> {
  ......
}
// { userName: string } | unknown => unknown,类型被模糊
asyncfunction fetchNameWithTimeout(userId: string): Promise<string> {
  ......
  return data.userName; // ❌ data 被推断为 unknown
}

unknown is ambiguous and requires us to narrow the type manually.

When we strictly use never to describe “unreachable code”, the compiler can help us to narrow the type exactly, so that the code is the document.

4.2.2.2 Use in conditional types

We often see never in conditional types, which are used to indicate the else case.

1
2
type Arguments<T> = T extends (...args: infer A) => any ? A : never
type Return<T> = T extends (...args: any[]) => infer R ? R : never

For the above two conditional types for deriving function arguments and return values, we can get a hint from the compiler even if the T passed in is a non-function type.

1
2
// Error: Type '3' is not assignable to type 'never'
const x: Return<"not a function type"> = 3;

Never also cleverly plays its role as a minimal factor when narrowing union types. Take, for example, the following example of excluding null and undefined from T.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
type NullOrUndefined = null | undefined
type NonNullable<T> = T extends NullOrUndefined ? never : T

// 运算过程
type NonNullable<string | null> 
  // 联合类型被分解成多个分支单独运算
  => (string extends NullOrUndefined ? never : string) | (nullextends  NullOrUndefined ? never : null)
  // 多个分支得到结果,再次联合
  => string | never
  // never 在联合类型运算中被消解
  => string

4.2.3 Exhaustive Check

Complex types such as union types and algebraic data types can be combined with the switch statement to narrow the types.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
interface Foo {
  type: 'foo'
}

interface Bar {
  type: 'bar'
}

type All = Foo | Bar;

function handleValue(val: All) {
  switch (val.type) {
    case'foo':
      // val 此时是 Foo
      break;
    case'bar':
      // val 此时是 Bar
      break;
    default:
      // val 此时是 never
      const exhaustiveCheck: never = val;
      break;
  }
}

If someone later modifies the All type, it will find that a compile error has been generated.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
type All = Foo | Bar | Baz;

function handleValue(val: All) {
  switch (val.type) {
    case'foo':
      // val 此时是 Foo
      break;
    case'bar':
      // val 此时是 Bar
      break;
    default:
      // val 此时是 Baz
      // ❌ Type 'Baz' is not assignable to type 'never'.(2322)
      const exhaustiveCheck: never = val;
      break;
  }
}

In the default branch, val is narrowed to Baz, making it impossible to assign a value to never and generating a compile error. Developers can realize that handleValue needs to have Baz-specific processing logic in it. By doing this, you can ensure that handleValue always exhausts all possible types.

5. Conclusion

For students who value type specification and code design, TypeScript is not a shackle, but a pragmatic language. By learning more about the use and status of never and unknown in the TypeScript type system, you can learn a lot about type system design and set theory, and organize reliable and safe code by narrowing types in practice.