长期以来,TypeScript一直被要求增加的一个特性是能够描述函数可能抛出的异常。这个特性通常被称为"抛出类型",在一些编程语言中用来帮助开发人员安全地调用函数。
例如,流行的强类型语言Java就使用throws
关键字来实现抛出类型。开发人员可以从以下代码中的第一行推断出,positive
函数可能会抛出ValueException
异常:
public static void positive(int value) throws ValueException { /* ...*/ }
抛出类型对于开发工具也非常有用,可以告诉编译器在调用函数时可能会抛出异常,而无需安全地使用try
/catch
处理。
那么,为什么TypeScript没有包括抛出类型呢?
简而言之,这在TypeScript中是不可行的,而且有些人认为在大多数编程语言中也不实用。本文将深入探讨包括抛出类型在内的功能的优缺点以及一般性的阻碍因素。让我们来看看吧!
抛出类型的好处
开发人员通常希望知道一个函数可能抛出的异常类型。将函数的异常描述与其参数和返回类型一起可以作为开发人员的有用文档。抛出类型还允许语言的类型检查器在调用函数时警告没有适当错误处理的情况。
例如,如果之前的Assert.positive
Java方法在不处理该情况的方法中使用,Java将会报告编译错误:
public void example() { Assert.positive(2); // ~ // Error: unreported exception ValueException; must be caught or declared to be thrown. }
另一种思考抛出异常的方式是,它们描述了函数的第二种返回类型。函数可以返回值,也可以抛出错误。传统的类型注解注释前者;抛出类型文档化了后者。
在理论上,文档化潜在异常听起来像是满足最小惊讶原则的一个好方法:系统的行为不应该让用户感到意外。显式地标记函数可能抛出的异常类型可以减少函数抛出异常时可能引起的意外。
检查异常
抛出类型通常与称为"检查异常"的特性一起使用,其中catch
子句能够注释它们可能捕获的异常类型。例如,Java允许在捕获的异常类型旁边添加类型注释,以运行特定于抛出异常类型的逻辑。
这段假设的Java代码对捕获的ValueException
运行特定逻辑,并且对其他Exception
运行更一般的逻辑:
public void example() { try { Assert.positive(-1); } catch (ValueException error) { System.out.println("Incorrect value: " + error.value); } catch (Exception error) { System.out.println("General error: " + error.message); } }
检查异常对于基于捕获异常类型运行不同逻辑很有用。像Java这样的强类型语言可以强制执行根据捕获异常类型运行正确的catch
块。
抛出类型的障碍
实际上,抛出类型不可行的原因有很多。这些原因从常见的JavaScript实践到在类型系统中真正表示抛出类型的困难都有。
未经检查的未类型化异常
在编码中的一个不幸现实是,大多数代码行可能会意外地抛出各种错误。即使看起来是类型安全的代码有时也会神秘地抛出错误,包括对象的getter和setter。
例如,将数组的length
设置为负数或太大的数字会抛出RangeError
:
[].length = -1; // Runtime error thrown: "RangeError: Invalid array length"
用户定义的getter和setter也可能会抛出错误。以下的Counter
类故意在其count
属性的getter中抛出错误:
class Counter { #counted = 0; get count() { if (!this.#counted) { throw new Error("Not ready yet."); } return this.#counted; } increment() { this.#counted += 1; } } const { count } = new Counter(); // Runtime error thrown: "Error: Not ready yet."
更糟糕的是,JavaScript不能保证抛出的对象是其内置Error
类的实例!以下代码,令人恐惧地,抛出四种类型之一,其中两种不是Error
实例:
function thisIsValidTypeScript() { switch (Math.floor(Math.random() * 4)) { case 0: throw new RangeError("Zero?!"); case 1: throw new Error("Gotcha!"); case 2: throw "a primitive string, not an Error"; case 3: throw null; } }
plugin:@typescript-eslint/only-throw-error
代码检查规则可以强制编写只会抛出Error
的代码。但它仅检查您自己的代码,而不检查任何依赖项的代码。
因此,TypeScript不能预测catch
子句中错误的类型。它必须默认使用"顶级"类型(允许任何其他类型的类型):默认为any
,或者当启用useUnknownInCatchVariables
时更安全的unknown
。
因此,在catch
块中,TypeScript代码必须使用类型断言和/或运行时类型检查来缩小捕获错误的类型:
try { thisIsValidTypeScript(); } catch (error) { if (error) { if (error instanceof RangeError) { console.warn("Out of range:", error.message); } else if (error instanceof Error) { console.warn("Caught an Error:", error.stack); } else { console.warn("Caught a non-Error:", error); } } else { console.error("I don't even know what this is:", error); } }
换句话说,即使函数可以有抛出类型,它们的异常类型实际上仍然是unknown
。在像JavaScript这样无法强制执行检查异常类型的运行时中,抛出类型的用处要小得多。
生态惯性
JavaScript和TypeScript开发人员没有现有的文化来记录函数可能抛出的异常。没有标准来表示哪些类型的函数调用或失败情况应该由异常和/或精心设计的返回类型表示。因此,尽管许多非TypeScript包有良好设计的值返回类型,它们的潜在抛出类型却令人意外地复杂。
这个问题的复杂性在于JavaScript社区喜欢创建许多依赖于彼此的小包。项目依赖树中的每个包可能有不同的错误处理方法。填写许多第三方包的抛出类型将是一项巨大的任务,无论它是否会对TypeScript开发人员有利。
不需要
像Java这样的旧语言之所以创建抛出异常,部分原因是因为它们不支持更丰富的语言功能,如联合类型。旨在结果为异常或某个值的方法无法返回Exception | Value
的联合。相反,他们通常使用值返回"happy"路径(Value
)和抛出异常来返回"unhappy"路径(Exception
)。
另一方面,JavaScript比许多传统的强类型语言更灵活。JavaScript和TypeScript包括多种功能,使得管理"happy"和"unhappy"路径更容易,包括后面描述的首选替代品:
First-class functions:提供内联函数,可以提供多个参数
Union types:允许返回值匹配多种可能的形状
现在,许多JavaScript和TypeScript开发人员更喜欢使用这些语言的灵活功能来避免抛出异常。通过这样做,他们减少了代码抛出异常的频率,减少了对抛出类型或检查异常的需求。
类型系统复杂性
TypeScript语言的每一个添加都会增加其类型系统的复杂性。抛出类型还需要考虑到类型检查器对函数类型的可赋值性检查。然而,能够定义不过度报告有效代码的抛出类型是棘手的。
以对象getter和setter可能抛出错误的情况为例。如果设置数组的length
属性总是需要添加抛出类型,对开发人员来说将会非常不便。但是,TypeScript无法在类型系统中表示比number
更精确的数值类型。对于可能触发错误的数值没有办法在类型系统中知道。
其他复杂的类型系统问题包括:
接口和对象类型属性应该如何表明它们可能抛出错误?
如果代码没有标记为抛出异常,是否仍应允许在其周围添加
try
块?类型注解应该如何指示函数的参数可能是一个可以抛出错误的函数,但这些错误不会被传递给调用代码?(例如
setTimeout
)
TypeScript添加抛出类型将需要开发人员学习这些答案,以有效地编写具有抛出类型的类型安全函数。即使答案是直截了当的,这仍然增加了开发人员需要理解的内容,以编写TypeScript代码。
首选替代方案
在使用类型化语言如TypeScript时,设计代码以便可以被语言的类型系统建模是很有用的。这样做可以使类型系统更好地理解代码,并为使用它的开发人员提供更多的帮助。
First-Class Functions
JavaScript以支持"first-class functions"而闻名,这意味着新函数可以作为函数参数和变量的值提供。许多在JavaScript中开发的API选择使用first-class functions而不是抛出异常。
例如,Node.js的fs.readFile
API在JavaScript Promise之前设计时要求开发人员提供在完成时调用的函数。该函数被调用时会传递两个参数,err
和data
,其中只有一个会提供一个值:
import fs from "node:fs"; fs.readFile("data.txt", (err, data) => { if (err) { console.error("Oh no:", err); } else { console.log("Got data:", data.toString()); } });
许多传统的强类型语言要么从未支持内联first-class函数,要么仅最近开始支持。first-class函数是一种方便的方法,允许多种结果类型。
Union Types
JavaScript允许函数返回任意数量的不同数据类型。TypeScript使用联合类型表示可能是几种可能类型之一的值。
TypeScript开发人员可能会将抛出Error
的函数切换为返回Error
或Value
:
考虑以下的getValueMaybe
函数,它要么返回Value
,要么抛出Error
:
interface Value { /* ... */ } declare function createValue(): Value; declare function readyForValues(): Boolean; function getValueMaybe() { if (!readyForValues()) { throw new Error("Wait!"); } const value: Value = { /* ... */ }; return value; } try { const value = getValueMaybe(); console.log("Got a value:", value); } catch (error) { console.error("Not ready to get value:", error); }
getValueMaybe
函数的一个重构可以将其返回类型改为Value | Error
,其中Error
类型表示尚未能运行的情况。TypeScript的类型系统将强制要求代码处理Error
情况,而不是假设返回值是Value
:
declare function createValue(): Value; declare function readyForValues(): Boolean; interface Value { /* ... */ } // ---cut--- function getValueMaybe() { return readyForValues() ? createValue() : new Error("Wait!"); } const value = getValueMaybe(); if (value instanceof Error) { console.error("Not ready to get value:", value); } else { console.log("Got a value:", value); }
其他常见的联合类型返回包括Value | undefined
,其中undefined
表示没有Value
可用,或者区分联合。
精确的准备状态
更全面的重构可能会尝试消除在可能抛出错误的情况下调用函数的可能性。精明的TypeScript开发人员可能会喜欢之前的createValue()
函数不会在其readyForValues()
之前被使用。
重构可能会将createValue()
函数包装在一个异步的"工厂"中,只有在准备好调用时才返回该函数:
// @lib: dom,esnext // @module: nodenext // @target: esnext export interface Value { /* ... */ } // ---cut--- async function getValueCreator() { // (wait until the value is ready to be created) return function createValue() { const value: Value = { /* ... */ }; return value; }; } const createValue = await getValueCreator(); const value = await createValue(); console.log("Got a value:", value);
并非所有的代码都可以重构为诸如工厂函数这样的替代策略。但是,在可能的情况下,工厂函数可以帮助使代码更自然地工作,避免错误状态。
无论您选择了哪种策略,都最好选择一种可以在您的语言类型系统中清晰表示的策略。
结语
抛出类型起初似乎是一个有用的想法,并且在帮助开发人员编写更安全代码方面确实有一定的用处。但特别是在像JavaScript这样更动态的生态系统中,其缺点远远超过了潜在的优势。将其添加到TypeScript生态系统将是一项巨大的工作,收益远低于在类型更为灵活的生态系统中的其他地方。
如果您希望实现更安全、可预测的函数调用,考虑使用其他策略。TypeScript中有几种很好的替代方案,包括first-class函数和联合类型。