TypeScript 是一种广泛使用的开源编程语言,非常适合现代化开发。借助它先进的类型系统,TypeScript 允许开发者编写更加强健、可维护和可扩展的代码。但是,要真正发挥 TypeScript 的威力并构建高质量的项目,了解和遵循最佳实践至关重要。在本文中,我们将深入探索 TypeScript 的世界,并探讨掌握该语言的 21 个最佳实践。这些最佳实践涵盖了各种主题,并提供了如何在真实项目中应用它们的具体示例。无论你是初学者还是经验丰富的 TypeScript 开发者,本文都将提供有价值的见解和技巧,帮助你编写干净高效的代码。
我们将从最基本的实践开始。想象一下,在问题出现之前就能发现潜在错误,听起来太好不过了吧?这正是 TypeScript 中严格类型检查所能为你做到的。这个最佳实践的目的是捕捉那些可能会悄悄溜进你的代码并在后面引发麻烦的虫子。
严格类型检查的主要作用是确保你的变量类型与你期望的类型匹配。这意味着,如果你声明一个变量为字符串类型,TypeScript 将确保分配给该变量的值确实是字符串而不是数字,例如。这有助于您及早发现错误,并确保您的代码按照预期工作。
启用严格类型检查只需在 tsconfig.json 文件中添加 "strict": true(默认为 true)即可。通过这样做,TypeScript 将启用一组检查,以捕获某些本应未被发现的错误。
以下是一个例子,说明严格类型检查如何可以帮助你避免常见错误:
let userName: string = "John";
userName = 123; // TypeScript will rAIse an error because "123" is not a string.
通过遵循这个最佳实践,你将能够及早发现错误,并确保你的代码按照预期工作,从而为你节省时间和不必要的麻烦。
TypeScript 的核心理念是显式地指定类型,但这并不意味着你必须在每次声明变量时都明确指定类型。
类型推断是 TypeScript 编译器根据变量赋值的值自动推断变量类型的能力。这意味着你不必在每次声明变量时都显式指定类型。相反,编译器会根据值推断类型。
例如,在以下代码片段中,TypeScript 会自动推断 name 变量的类型为字符串:
let name = "John"。
类型推断在处理复杂类型或将变量初始化为从函数返回的值时特别有用。
但是请记住,类型推断并不是一个魔法棒,有时候最好还是显式指定类型,特别是在处理复杂类型或确保使用特定类型时。
Linters 是一种可以通过强制一组规则和指南来帮助你编写更好代码的工具。它们可以帮助你捕捉潜在的错误,提高代码的整体质量。
有几个针对 TypeScript 的 Linters 可供选择,例如 TSLint 和 ESLint,可以帮助你强制执行一致的代码风格并捕捉潜在的错误。这些 Linters 可以配置检查诸如缺少分号、未使用的变量和其他常见问题等事项。
当涉及到编写干净、可维护的代码时,接口是你的好朋友。它们就像是对象的蓝图,概述了你将要使用的数据的结构和属性。
在 TypeScript 中,接口定义了对象的形状的约定。它指定了该类型的对象应具有的属性和方法,并且可以用作变量的类型。这意味着,当你将一个对象分配给带有接口类型的变量时,TypeScript 会检查对象是否具有接口中指定的所有属性和方法。
以下是 TypeScript 中定义和使用接口的示例:
interface User {
name: string;
age: number;
}
let user: User = {name: "John", age: 25};
接口还可以使代码重构更容易,因为它确保了使用某个特定类型的所有位置都会被一次性更新。
TypeScript 允许你使用类型别名(type aliases)创建自定义类型。类型别名和接口(interface)的主要区别在于,类型别名为类型创建一个新名称,而接口为对象的形状创建一个新名称。
例如,你可以使用类型别名为二维空间中的点创建一个自定义类型:
type Point = { x: number, y: number };
let point: Point = { x: 0, y: 0 };
类型别名也可以用于创建复杂类型,例如联合类型(union type)或交叉类型(intersection type)。
type User = { name: string, age: number };
type Admin = { name: string, age: number, privileges: string[] };
type SuperUser = User & Admin;
元组是一种表示具有不同类型的固定大小元素数组的方式。它们允许你用特定的顺序和类型表示值的集合。
例如,你可以使用元组来表示二维空间中的一个点:
let point: [number, number] = [1, 2];
你还可以使用元组来表示多个类型的集合:
let user: [string, number, boolean] = ["Bob", 25, true];
使用元组的主要优势之一是,它们提供了一种在集合中表达特定类型关系的方式。
此外,你可以使用解构赋值来提取元组的元素并将它们分配给变量:
let point: [number, number] = [1, 2];
let [x, y] = point;
console.log(x, y);
有时,我们可能没有有关变量类型的所有信息,但仍然需要在代码中使用它。在这种情况下,我们可以利用 any 类型。但是,像任何强大的工具一样,使用 any 应该谨慎和有目的地使用。
使用 any 的一个最佳实践是将其使用限制在真正未知类型的特定情况下,例如在使用第三方库或动态生成的数据时。此外,最好添加类型断言或类型保护,以确保变量被正确使用。尽可能缩小变量类型的范围。
例如:
function logData(data: any) {
console.log(data);
}
const user = { name: "John", age: 30 };
const numbers = [1, 2, 3];
logData(user); // { name: "John", age: 30 }
logData(numbers); // [1, 2, 3]
另一个最佳实践是避免在函数返回类型和函数参数中使用 any,因为它可能会削弱代码的类型安全性。相反,你可以使用更具体的类型或使用一些提供一定程度类型安全的更通用的类型,如 unknown 或 object。
unknown 类型是 TypeScript 3.0 中引入的一种强大且限制性更强的类型。它比 any 类型更具限制性,并可以帮助你防止意外的类型错误。
与 any 不同的是,当你使用 unknown 类型时,除非你首先检查其类型,否则 TypeScript 不允许你对值执行任何操作。这可以帮助你在编译时捕捉到类型错误,而不是在运行时。
例如,你可以使用 unknown 类型创建一个更加类型安全的函数:
function printValue(value: unknown) {
if (typeof value === "string") {
console.log(value);
} else {
console.log("Not a string");
}
}
你也可以使用 unknown 类型创建更加类型安全的变量:
let value: unknown = "hello";
let str: string = value; // Error: Type 'unknown' is not assignable to type 'string'.
在 TypeScript 中,never 是一个特殊的类型,表示永远不会发生的值。它用于指示函数不会正常返回,而是会抛出错误。这是一种很好的方式,可以向其他开发人员(和编译器)指示一个函数不能以某种方式使用,这可以帮助捕捉潜在的错误。
例如,考虑以下函数,如果输入小于 0,则会抛出错误:
function divide(numerator: number, denominator: number): number {
if (denominator === 0) {
throw new Error("Cannot divide by zero");
}
return numerator / denominator;
}
这里,函数 divide 声明为返回一个数字,但如果分母为零,则会抛出错误。为了指示在这种情况下该函数不会正常返回,你可以使用 never 作为返回类型:
function divide(numerator: number, denominator: number): number | never {
if (denominator === 0) {
throw new Error("Cannot divide by zero");
}
return numerator / denominator;
}
keyof 运算符是 TypeScript 的一个强大功能,可以创建一个表示对象键的类型。它可以用于明确指示哪些属性是对象允许的。
例如,你可以使用 keyof 运算符为对象创建更可读和可维护的类型:
interface User {
name: string;
age: number;
}
type UserKeys = keyof User; // "name" | "age"
你还可以使用 keyof 运算符创建更加类型安全的函数,将对象和键作为参数:
function getProperty<T, K extends keyof T>(obj: T, key: K) {
return obj[key];
}
这将允许你在编译时检查 key 是否为对象 T 的键之一,并返回该键对应的值。
function getValue<T, K extends keyof T>(obj: T, key: K) {
return obj[key];
}
let user: User = { name: "John", age: 30 };
console.log(getValue(user, "name")); // "John"
console.log(getValue(user, "gender")); // Error: Argument of type '"gender"' is not assignable to parameter of type '"name" | "age"'.
枚举(Enums)是 TypeScript 中定义一组命名常量的一种方式。它们可以用于创建更具可读性和可维护性的代码,通过给一组相关的值赋予有意义的名称。
例如,你可以使用枚举来定义一个订单可能的状态值:
enum OrderStatus {
Pending,
Processing,
Shipped,
Delivered,
Cancelled
}
let orderStatus: OrderStatus = OrderStatus.Pending;
枚举还可以有自定义的一组数字值或字符串值:
enum OrderStatus {
Pending = 1,
Processing = 2,
Shipped = 3,
Delivered = 4,
Cancelled = 5
}
let orderStatus: OrderStatus = OrderStatus.Pending;
在命名约定方面,枚举应该以第一个大写字母命名,并且名称应该是单数形式。
命名空间(Namespaces)是一种组织代码和防止命名冲突的方法。它们允许你创建一个容器来定义变量、类、函数和接口。
例如,你可以使用命名空间来将所有与特定功能相关的代码分组:
namespace OrderModule {
export class Order { /* … / }
export function cancelOrder(order: Order) { / … / }
export function processOrder(order: Order) { / … */ }
}
let order = new OrderModule.Order();
OrderModule.cancelOrder(order);
你也可以使用命名空间来为你的代码提供一个独特的名称,以防止命名冲突:
namespace MyCompany.MyModule {
export class MyClass { /* … */ }
}
let myClass = new MyCompany.MyModule.MyClass();
需要注意的是,命名空间类似于模块,但它们用于组织代码和防止命名冲突,而模块用于加载和执行代码。
实用类型(Utility Types)是 TypeScript 中内置的一种特性,提供了一组预定义类型,可以帮助你编写更好的类型安全代码。它们允许你执行常见的类型操作,并以更方便的方式操作类型。
例如,你可以使用 Pick 实用类型从对象类型中提取一组属性:
type User = { name: string, age: number, email: string };
type UserInfo = Pick<User, "name" | "email">;
你也可以使用 Exclude 实用类型从对象类型中删除属性:
type User = { name: string, age: number, email: string };
type UserWithoutAge = Exclude<User, "age">;
你可以使用 Partial 实用类型将类型的所有属性设置为可选的:
type User = { name: string, age: number, email: string };
type PartialUser = Partial<User>;
除了上述实用类型外,还有许多其他实用类型,如 Readonly、Record、Omit、Required 等,可以帮助你编写更好的类型安全代码。
当在 TypeScript 中处理数据时,你可能希望确保某些值无法更改。这就是“只读”和“只读数组”的用武之地。
“只读”关键字用于使对象的属性只读,意味着在创建后它们无法被修改。例如,在处理配置或常量值时,这非常有用。
interface Point {
x: number;
y: number;
}
let point: Readonly<Point> = {x: 0, y: 0};
point.x = 1; // TypeScript会报错,因为“point.x”是只读的
“只读数组”与“只读”类似,但是用于数组。它使一个数组变成只读状态,在创建后不能被修改。
let numbers: ReadonlyArray<number> = [1, 2, 3];
numbers.push(4); // TypeScript会报错,因为“numbers”是只读的
在 TypeScript 中,处理复杂类型时,很难跟踪变量的不同可能性。类型保护是一种强大的工具,可以根据特定条件缩小变量的类型范围。
以下是如何使用类型保护检查变量是否为数字的示例:
function isNumber(x: any): x is number {
return typeof x === "number";
}
let value = 3;
if (isNumber(value)) {
value.toFixed(2); // TypeScript 知道 "value" 是一个数字,因为有了类型保护
}
类型保护还可以与“in”运算符、typeof 运算符和 instanceof 运算符一起使用。
泛型是 TypeScript 的一个强大特性,可以让你编写可以与任何类型一起使用的代码,从而使其更具有可重用性。泛型允许你编写一个单独的函数、类或接口,可以与多种类型一起使用,而不必为每种类型编写单独的实现。
例如,你可以使用泛型函数来创建任何类型的数组:
function createArray<T>(length: number, value: T): Array<T> {
let result = [];
for (let i = 0; i < length; i++) {
result[i] = value;
}
return result;
}
let names = createArray<string>(3, "Bob");
let numbers = createArray<number>(3, 0);
你也可以使用泛型来创建一个可以处理任何类型数据的类:
class GenericNumber<T> {
zeroValue: T;
add: (x: T, y: T) => T;
}
let myGenericNumber = new GenericNumber<number>();
myGenericNumber.zeroValue = 0;
myGenericNumber.add = function(x, y) { return x + y; };
infer 关键字是 TypeScript 的一个强大特性,它允许你从一个类型中提取出变量的类型。
例如,你可以使用 infer 关键字为返回特定类型数组的函数创建更精确的类型:
type ArrayType<T> = T extends (infer U)[] ? U : never;
type MyArray = ArrayType<string[]>; // MyArray 类型是 string
你也可以使用 infer 关键字为返回具有特定属性的对象的函数创建更精确的类型:
type Person = { name: string, age: number };
type PersonName = keyof Person;
type PersonProperty<T> = T extends { [K in keyof T]: infer U } ? U : never;
type Name = PersonProperty<Person>;
在上面的例子中,我们使用了 infer 关键字来提取出对象的属性类型,这个技巧可以用于创建更准确的类型定义。
type ObjectType<T> = T extends { [key: string]: infer U } ? U : never;
type MyObject = ObjectType<{ name: string, age: number }>; // MyObject is of type {name:string, age: number}
条件类型允许我们表达更复杂的类型关系。基于其他类型的条件创建新类型。
例如,可以使用条件类型来提取函数的返回类型:
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : any;
type R1 = ReturnType<() => string>; // string
type R2 = ReturnType<() => void>; // void
还可以使用条件类型来提取对象类型的属性,满足特定条件:
type PickProperties<T, U> = { [K in keyof T]: T[K] extends U ? K : never }[keyof T];
type P1 = PickProperties<{ a: number, b: string, c: boolean }, string | number>; // "a" | "b"
映射类型是一种基于现有类型创建新类型的方式。通过对现有类型的属性应用一组操作来创建新类型。
例如,可以使用映射类型创建一个表示现有类型只读版本的新类型:
type Readonly<T> = { readonly [P in keyof T]: T[P] };
let obj: { a: number, b: string } = { a: 1, b: "hello" };
let readonlyObj: Readonly<typeof obj> = { a: 1, b: "hello" };
还可以使用映射类型创建一个表示现有类型可选版本的新类型:
type Optional<T> = { [P in keyof T]?: T[P] };
let obj: { a: number, b: string } = { a: 1, b: "hello" };
let optionalObj: Optional<typeof obj> = { a: 1 };
映射类型可以以不同的方式使用:创建新类型、从现有类型中添加或删除属性,或更改现有类型的属性类型。
装饰器是一种使用简单语法来为类、方法或属性添加额外功能的方式。它们是一种增强类的行为而不修改其实现的方式。
例如,可以使用装饰器为方法添加日志记录:
function logMethod(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
let originalMethod = descriptor.value;
descriptor.value = function(…args: any[]) {
console.log(Calling ${propertyKey} with args: ${JSON.stringify(args)});
let result = originalMethod.Apply(this, args);
console.log(Called ${propertyKey}, result: ${result});
return result;
}
}
class Calculator {
@logMethod
add(x: number, y: number): number {
return x + y;
}
}
还可以使用装饰器为类、方法或属性添加元数据,这些元数据可以在运行时使用。
function setApiPath(path: string) {
return function (target: any) {
target.prototype.apiPath = path;
}
}
@setApiPath("/users")
class UserService {
// …
}
console.log(new UserService().apiPath); // "/users"
本文主要介绍了 TypeScript 的 20 个最佳实践,旨在提高代码质量和开发效率。其中,一些最佳实践包括尽可能使用 TypeScript 的类型系统、使用函数和方法参数默认值、使用可选链操作符等。此外,该文章还强调了在使用类时,应该使用访问修饰符,以避免出现不必要的错误。
该文章指出,使用 TypeScript 的类型系统可以帮助开发人员避免一些常见的错误,例如在运行时引发异常。此外,还提供了一些关于如何编写类型注释的最佳实践。例如,应该尽可能使用函数和方法参数默认值,以避免参数为空或未定义时的错误。
文章中还介绍了一些如何使用 TypeScript 的高级特性的最佳实践,例如使用类型别名和枚举,以提高代码的可读性和可维护性。此外,该文章还强调了如何使用可选链操作符来避免一些运行时错误。
总之,该文章提供了许多有用的 TypeScript 最佳实践,这些实践可以帮助开发人员编写更高质量的代码,提高开发效率,避免一些常见的错误。
本文转载自微信公众号「大迁世界」