MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

将空值推到TypeScript类型边界的实践

2022-09-236.9k 阅读

理解 TypeScript 中的空值概念

在深入探讨将空值推到 TypeScript 类型边界的实践之前,我们首先要对 TypeScript 中的空值概念有清晰的认识。在 TypeScript 里,空值主要指 nullundefined

在JavaScript中,null 通常表示一个有意设置的空值,它是一个表示 “无” 的对象,而 undefined 则表示变量声明了但未初始化,或者对象中不存在的属性。在TypeScript里,这两者有着明确的类型定义。

空值类型声明

我们可以在变量声明时明确指定其类型为空值。例如:

let myNull: null = null;
let myUndefined: undefined = undefined;

这里,myNull 被明确声明为 null 类型,myUndefined 被声明为 undefined 类型。

与其他类型的关系

空值类型与其他类型之间存在特定的关系。默认情况下,nullundefined 是所有类型的子类型。这意味着我们可以将 nullundefined 赋值给其他类型的变量,除非开启了 strictNullChecks 选项。

let num: number;
num = null; // 在未开启 strictNullChecks 时不会报错
num = undefined; // 在未开启 strictNullChecks 时不会报错

开启 strictNullChecks

strictNullChecks 是 TypeScript 中一个非常重要的选项,它能让我们更严格地处理空值。当开启 strictNullChecks 后,nullundefined 只能赋值给 void 类型或它们自身。

配置 strictNullChecks

tsconfig.json 文件中,将 "strictNullChecks": true 即可开启该选项。例如:

{
  "compilerOptions": {
    "strictNullChecks": true
  }
}

代码中的体现

开启 strictNullChecks 后,之前的代码会报错:

let num: number;
num = null; // 报错:Type 'null' is not assignable to type 'number'
num = undefined; // 报错:Type 'undefined' is not assignable to type 'number'

这样能有效避免在代码中意外地将空值赋值给非空类型的变量,减少运行时错误。

空值类型保护

当我们在代码中处理可能为空的值时,需要进行类型保护,以确保在使用这些值时它们不为空。

typeof 类型保护

typeof 操作符可以用于检查变量的类型,包括是否为空值。例如:

function printValue(value: string | null) {
  if (typeof value === 'string') {
    console.log(value.length);
  }
}

这里通过 typeof value ==='string' 来确保 valuestring 类型,而不是 null,从而可以安全地访问 length 属性。

instanceof 类型保护

instanceof 用于检查对象是否是某个类的实例,同样也可以用于空值类型保护。例如:

class MyClass {}
function handleValue(value: MyClass | null) {
  if (value instanceof MyClass) {
    value.someMethod();
  }
}

通过 value instanceof MyClass 确保 valueMyClass 的实例,而不是 null,这样就可以安全地调用 someMethod 方法。

可选参数和属性

在函数参数和对象属性中,我们经常会遇到可能为空的情况,TypeScript 提供了可选参数和属性的语法来处理。

可选函数参数

function greet(name: string, greeting?: string) {
  if (greeting) {
    console.log(`${greeting}, ${name}!`);
  } else {
    console.log(`Hello, ${name}!`);
  }
}
greet('John');
greet('Jane', 'Hi');

这里 greeting 参数是可选的,在函数内部通过 if (greeting) 来检查它是否被提供。

可选对象属性

interface User {
  name: string;
  age?: number;
}
function printUser(user: User) {
  console.log(`Name: ${user.name}`);
  if (user.age) {
    console.log(`Age: ${user.age}`);
  }
}
const user1: User = { name: 'Bob' };
const user2: User = { name: 'Alice', age: 30 };
printUser(user1);
printUser(user2);

User 接口中,age 属性是可选的,在 printUser 函数中通过 if (user.age) 来决定是否打印年龄。

非空断言操作符

有时候,我们明确知道一个值不会为空,即使TypeScript认为它可能为空,这时可以使用非空断言操作符 !

示例

let str: string | null = "Hello";
let length: number = str!.length; // 使用非空断言操作符

这里通过 str! 告诉 TypeScript,我们确定 str 不为空,从而可以直接访问其 length 属性。但使用非空断言操作符时需谨慎,因为如果实际值为空,会导致运行时错误。

联合类型与空值

联合类型在处理可能为空的值时非常有用,我们可以将空值类型与其他类型组成联合类型。

函数返回联合类型

function getValue(): string | null {
  // 这里可能返回 string 或 null
  return Math.random() > 0.5? "Some value" : null;
}
let result = getValue();
if (result) {
  console.log(result.length);
}

getValue 函数返回 string | null 联合类型,在调用函数后通过 if (result) 来判断返回值是否为空。

变量声明为联合类型

let data: number | undefined;
if (data!== undefined) {
  console.log(data + 10);
}

这里 data 被声明为 number | undefined 联合类型,通过 if (data!== undefined) 来确保在使用 data 时它不为 undefined

交叉类型与空值

交叉类型是将多个类型合并为一个类型,在处理空值时也有其独特的应用场景。

示例

interface A {
  propA: string;
}
interface B {
  propB: number;
}
let value: (A & B) | null;
if (value) {
  console.log(value.propA);
  console.log(value.propB);
}

这里 value(A & B) | null 类型,通过 if (value) 来确保在访问 propApropBvalue 不为空。

类型别名与空值

类型别名可以用来定义包含空值的复杂类型,使代码更易读和维护。

定义类型别名

type MaybeNumber = number | null | undefined;
function addNumbers(a: MaybeNumber, b: MaybeNumber): MaybeNumber {
  if (a!== null && a!== undefined && b!== null && b!== undefined) {
    return a + b;
  }
  return null;
}

这里通过 type MaybeNumber = number | null | undefined 定义了一个类型别名 MaybeNumber,在 addNumbers 函数中使用该别名来处理可能为空的数字相加。

泛型与空值

泛型在处理空值时提供了更高的灵活性,能让我们编写可复用的代码来处理不同类型的空值情况。

泛型函数

function handleNullableValue<T>(value: T | null): T | null {
  if (value === null) {
    return null;
  }
  return value;
}
let numResult = handleNullableValue<number>(5);
let strResult = handleNullableValue<string>(null);

这里 handleNullableValue 是一个泛型函数,它可以处理任意类型的可能为空的值。

泛型类

class Maybe<T> {
  private value: T | null;
  constructor(value: T | null) {
    this.value = value;
  }
  get(): T | null {
    return this.value;
  }
}
let maybeNumber = new Maybe<number>(10);
let maybeString = new Maybe<string>(null);

Maybe 类是一个泛型类,它可以包装可能为空的值,通过泛型 T 可以处理不同类型。

将空值推到类型边界的实践场景

函数参数验证

在函数接收参数时,将空值推到类型边界可以确保函数在正确的输入下运行。例如:

function divide(a: number, b: number): number | null {
  if (b === 0) {
    return null;
  }
  return a / b;
}
let result = divide(10, 2);
if (result!== null) {
  console.log(result);
}

这里 divide 函数在 b 为 0 时返回 null,调用者需要处理返回的空值情况。

数据获取与处理

在从 API 获取数据或读取文件等操作中,数据可能为空。我们可以将空值推到类型边界来处理。

async function fetchData(): Promise<string | null> {
  // 模拟异步数据获取
  return Math.random() > 0.5? "Some data" : null;
}
fetchData().then(data => {
  if (data) {
    console.log(data.length);
  }
});

这里 fetchData 函数返回可能为空的数据,在处理数据时通过 if (data) 来判断是否为空。

链式调用与空值处理

在进行链式调用时,可能会遇到某个环节返回空值的情况。我们可以将空值推到类型边界来确保链式调用的正确性。

class Chainable {
  private value: string | null;
  constructor(value: string | null) {
    this.value = value;
  }
  addSuffix(suffix: string): Chainable {
    if (this.value) {
      this.value += suffix;
    }
    return this;
  }
  getValue(): string | null {
    return this.value;
  }
}
let chain = new Chainable("Hello").addSuffix(" World");
let resultValue = chain.getValue();
if (resultValue) {
  console.log(resultValue);
}

这里 Chainable 类在 addSuffix 方法中处理了 this.value 可能为空的情况,在获取最终值时也需要处理空值。

高级技巧:使用工具类型处理空值

TypeScript 提供了一些工具类型,在处理空值时能发挥很大作用。

Required 与 Partial

Required 可以将所有属性变为必选,而 Partial 则可以将所有属性变为可选。

interface User {
  name: string;
  age: number;
}
let partialUser: Partial<User> = {};
let requiredUser: Required<User> = { name: 'Tom', age: 25 };

当我们需要处理可能为空的对象属性时,Partial 非常有用,而 Required 可以用于确保对象属性都有值。

Exclude 与 Extract

Exclude 可以从一个类型中排除另一个类型的成员,Extract 则可以提取两个类型共有的成员。

type AllTypes = string | number | null | undefined;
type NonNullableTypes = Exclude<AllTypes, null | undefined>;
type NullableTypes = Extract<AllTypes, null | undefined>;

这里通过 Exclude 得到了非空类型,通过 Extract 得到了空值类型。

实践中的注意事项

过度保护导致代码复杂

在处理空值时,过度的类型保护可能会使代码变得复杂且难以维护。例如:

function complexFunction(a: string | null, b: number | null) {
  if (a && b) {
    // 执行一些操作
  } else if (a &&!b) {
    // 执行另一些操作
  } else if (!a && b) {
    // 执行其他操作
  } else {
    // 处理 a 和 b 都为空的情况
  }
}

这样的代码逻辑分支过多,可读性较差。我们应该尽量简化逻辑,确保代码清晰。

忽视运行时检查

虽然 TypeScript 提供了强大的类型检查,但在运行时仍然可能出现空值问题。例如,在动态加载模块或使用第三方库时,可能会绕过 TypeScript 的类型检查。因此,即使在类型层面处理了空值,运行时也需要适当的检查。

与其他开发人员协作

在团队开发中,不同的开发人员对空值处理可能有不同的习惯和理解。因此,建立统一的编码规范和最佳实践非常重要,以确保代码的一致性和可维护性。

通过深入理解和实践上述关于将空值推到 TypeScript 类型边界的内容,我们可以编写出更健壮、可靠的 TypeScript 代码,减少因空值导致的运行时错误,提高代码的质量和可维护性。无论是在小型项目还是大型企业级应用中,合理处理空值都是至关重要的一环。