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

名义类型在TypeScript中的实现方法

2022-09-302.7k 阅读

什么是名义类型

在编程语言的类型系统中,类型的定义和比较方式有多种,名义类型(Nominal Typing)是其中一种重要的方式。名义类型系统中,类型的相等性基于类型的名称,而非其结构。简单来说,如果两个类型有着不同的名称,即便它们内部的结构完全一样,在名义类型系统中也被视为不同的类型。

举个现实生活中的例子,假设我们有两个职业:“软件工程师”和“程序员”,从实际工作内容来看,他们可能做着非常相似的事情,即编写代码、解决问题等。但是从职业名称角度,“软件工程师”和“程序员”是不同的职业称呼。在名义类型系统里,就如同这两个不同称呼的职业,即便内部结构(工作内容)相似,只要名称不同,就是不同的类型。

与之相对的是结构类型(Structural Typing),结构类型系统中,类型的相等性取决于它们的结构。只要两个类型的结构一致,它们就被认为是相同的类型,而不关心类型的名称。

TypeScript的类型系统基础

TypeScript 是 JavaScript 的超集,它为 JavaScript 添加了静态类型系统。TypeScript 的类型系统主要基于结构类型,但在某些场景下,我们可能需要实现名义类型的效果。

在 TypeScript 中,最基本的类型声明方式包括原始类型(如 stringnumberboolean 等)、对象类型、数组类型等。例如:

let num: number = 10;
let str: string = "hello";

let person: { name: string; age: number } = { name: "John", age: 30 };

let numbers: number[] = [1, 2, 3];

这里我们可以看到,TypeScript 根据变量的赋值推断或者显式声明来确定其类型。在对象类型中,只要对象的结构符合指定的类型结构,就被认为是匹配的。例如:

let anotherPerson: { name: string; age: number };
anotherPerson = { name: "Jane", age: 25 };

这里 anotherPerson 变量的类型和前面定义的 person 变量的类型,在结构类型系统中是相同的,因为它们都具有 namestring 类型和 agenumber 类型的结构。

在TypeScript中模拟名义类型的方法

利用类和继承

在 TypeScript 中,类是一种定义类型的方式。通过类的继承关系,我们可以在一定程度上模拟名义类型。

假设我们有两个看似结构相同但希望被视为不同类型的场景,比如“UserId”和“ProductId”,它们本质上都是数字类型,但代表不同的业务含义。我们可以这样定义:

class UserId {
    constructor(public value: number) {}
}

class ProductId {
    constructor(public value: number) {}
}

function getUserById(id: UserId) {
    // 这里处理获取用户的逻辑,id 只能是 UserId 类型
    console.log(`Fetching user with id: ${id.value}`);
}

function getProductById(id: ProductId) {
    // 这里处理获取产品的逻辑,id 只能是 ProductId 类型
    console.log(`Fetching product with id: ${id.value}`);
}

let userId = new UserId(1);
let productId = new ProductId(2);

getUserById(userId);
// getUserById(productId); // 这行代码会报错,因为 productId 是 ProductId 类型,不是 UserId 类型
getProductById(productId);
// getProductById(userId); // 这行代码会报错,因为 userId 是 UserId 类型,不是 ProductId 类型

在上述代码中,UserIdProductId 虽然内部都只有一个 value 属性且类型为 number,但由于它们是不同的类,所以在函数参数类型检查时被视为不同的类型。这就模拟了名义类型的效果,即类型的区分基于名称(类名)。

使用类型别名和类型守卫

类型别名是给类型起一个新名字的方式。结合类型守卫,我们也可以实现类似名义类型的效果。

type UserId = { _tag: "UserId"; value: number };
type ProductId = { _tag: "ProductId"; value: number };

function isUserId(id: any): id is UserId {
    return typeof id === 'object' && '_tag' in id && id._tag === 'UserId';
}

function isProductId(id: any): id is ProductId {
    return typeof id === 'object' && '_tag' in id && id._tag === 'ProductId';
}

function getUserById(id: UserId) {
    console.log(`Fetching user with id: ${id.value}`);
}

function getProductById(id: ProductId) {
    console.log(`Fetching product with id: ${id.value}`);
}

let userId: any = { _tag: "UserId", value: 1 };
let productId: any = { _tag: "ProductId", value: 2 };

if (isUserId(userId)) {
    getUserById(userId);
}

if (isProductId(productId)) {
    getProductById(productId);
}

这里我们通过类型别名定义了 UserIdProductId,并使用 _tag 字段来区分不同的类型。isUserIdisProductId 函数作为类型守卫,用于在运行时检查变量是否为特定的类型。这种方式虽然没有像类那样有明确的名称定义,但通过 _tag 和类型守卫也实现了名义类型的部分效果,即能够区分看似结构相同但实际含义不同的类型。

利用模块作用域

在 TypeScript 中,模块(importexport)有自己的作用域。我们可以利用模块的这种特性来创建名义类型。

假设有两个模块 userIdModuleproductIdModule

// userIdModule.ts
export type UserId = number;
const _privateMarker = Symbol("UserId");
export function createUserId(value: number): UserId {
    return value;
}
// productIdModule.ts
export type ProductId = number;
const _privateMarker = Symbol("ProductId");
export function createProductId(value: number): ProductId {
    return value;
}
// main.ts
import { UserId, createUserId } from './userIdModule';
import { ProductId, createProductId } from './productIdModule';

function getUserById(id: UserId) {
    console.log(`Fetching user with id: ${id}`);
}

function getProductById(id: ProductId) {
    console.log(`Fetching product with id: ${id}`);
}

let userId = createUserId(1);
let productId = createProductId(2);

getUserById(userId);
// getUserById(productId); // 这行代码会报错,因为 productId 是 ProductId 类型,不是 UserId 类型
getProductById(productId);
// getProductById(userId); // 这行代码会报错,因为 userId 是 UserId 类型,不是 ProductId 类型

在上述代码中,虽然 UserIdProductId 在类型上都是 number,但由于它们处于不同的模块作用域,并且通过模块导出的方式进行使用,在类型检查时会被视为不同的类型。这也是一种在 TypeScript 中模拟名义类型的有效方式。

名义类型在实际项目中的应用场景

数据模型的区分

在大型项目中,不同的数据模型可能具有相似的结构,但代表完全不同的业务实体。例如,在一个电商系统中,“订单编号”和“商品编号”可能都是字符串类型,但它们代表不同的业务概念。使用名义类型可以明确区分这两种类型,避免在代码中混淆。

class OrderId {
    constructor(public value: string) {}
}

class ProductId {
    constructor(public value: string) {}
}

function getOrderById(id: OrderId) {
    // 获取订单的逻辑
    console.log(`Fetching order with id: ${id.value}`);
}

function getProductById(id: ProductId) {
    // 获取商品的逻辑
    console.log(`Fetching product with id: ${id.value}`);
}

let orderId = new OrderId("12345");
let productId = new ProductId("abcdef");

getOrderById(orderId);
// getOrderById(productId); // 报错,productId 不是 OrderId 类型
getProductById(productId);
// getProductById(orderId); // 报错,orderId 不是 ProductId 类型

这样可以提高代码的可读性和健壮性,在函数调用时明确参数类型的业务含义,减少潜在的错误。

安全的类型转换

在一些情况下,我们可能需要进行类型转换。使用名义类型可以使类型转换更加安全。比如,在一个金融应用中,我们有“美元金额”和“人民币金额”两种类型,它们本质上都是数字,但有着不同的含义。

class USDAmount {
    constructor(public value: number) {}

    convertToCNY(rate: number): CNYAmount {
        return new CNYAmount(this.value * rate);
    }
}

class CNYAmount {
    constructor(public value: number) {}

    convertToUSD(rate: number): USDAmount {
        return new USDAmount(this.value / rate);
    }
}

let usd = new USDAmount(100);
let cny = usd.convertToCNY(6.5);

// 这里如果没有名义类型,可能会错误地将其他数字类型当作 USDAmount 或 CNYAmount 进行转换

通过名义类型,我们可以在类型转换的方法中明确输入和输出的类型,确保类型转换的安全性和正确性。

函数重载与名义类型

在函数重载的场景中,名义类型可以帮助我们更清晰地定义不同参数类型的函数行为。例如,在一个图形绘制库中,我们可能有绘制圆形和绘制矩形的函数,虽然它们可能都接受一些数值参数,但参数的含义不同。

class Circle {
    constructor(public radius: number) {}
}

class Rectangle {
    constructor(public width: number, public height: number) {}
}

function drawShape(shape: Circle): void;
function drawShape(shape: Rectangle): void;
function drawShape(shape: Circle | Rectangle) {
    if (shape instanceof Circle) {
        console.log(`Drawing a circle with radius ${shape.radius}`);
    } else {
        console.log(`Drawing a rectangle with width ${shape.width} and height ${shape.height}`);
    }
}

let circle = new Circle(5);
let rectangle = new Rectangle(10, 5);

drawShape(circle);
drawShape(rectangle);

这里通过名义类型(CircleRectangle 类),我们可以清晰地定义 drawShape 函数在不同参数类型下的行为,提高代码的可读性和可维护性。

名义类型与结构类型的权衡

代码的灵活性与严谨性

结构类型在 TypeScript 中提供了很大的灵活性。例如,当我们定义一个接受对象类型参数的函数时,只要传入的对象具有符合要求的结构,就可以被接受,而不需要严格的类型名称匹配。这在快速开发和代码复用方面具有很大优势。

function printName(obj: { name: string }) {
    console.log(`Name: ${obj.name}`);
}

let person = { name: "John", age: 30 };
printName(person);

let anotherObj = { name: "Jane" };
printName(anotherObj);

这里 personanotherObj 虽然不是同一类型的严格定义,但由于结构符合 printName 函数的参数要求,所以都可以被接受。

然而,这种灵活性在一些场景下可能导致错误。比如,如果我们希望明确区分不同含义的对象类型,结构类型可能无法满足需求,而名义类型则可以提供更严谨的类型检查,减少潜在错误。

开发效率与维护成本

在开发初期,结构类型可以让开发人员更快速地编写代码,因为不需要过多关注类型的名称定义,只要结构匹配即可。但随着项目规模的扩大,代码的维护成本可能会增加。因为结构相似但含义不同的类型可能会在代码中被误用。

名义类型在开发初期可能需要更多的代码来定义和维护不同的类型,但在长期维护中,由于类型的明确区分,可以减少错误的发生,降低维护成本。特别是在团队协作开发中,名义类型可以让其他开发人员更清晰地理解代码中不同类型的含义。

兼容性与可扩展性

TypeScript 基于 JavaScript,而 JavaScript 本身是动态类型语言,结构类型更符合 JavaScript 的动态特性,在与现有 JavaScript 代码集成时具有更好的兼容性。

然而,当项目需要进行功能扩展,特别是涉及到不同业务含义的类型区分时,名义类型可以更好地支持扩展。例如,在电商系统中,如果需要新增一种“优惠券编号”类型,使用名义类型可以很清晰地与现有的“订单编号”和“商品编号”类型区分开来,而不会对现有代码造成过多的影响。

深入理解TypeScript中的类型兼容性

结构类型兼容性规则

在 TypeScript 中,结构类型兼容性是基于赋值兼容性的。简单来说,如果类型 A 的所有成员都可以赋值给类型 B 的对应成员,那么 A 兼容 B

对于对象类型:

let obj1: { a: number; b: string };
let obj2: { a: number; b: string; c: boolean };

obj1 = obj2; // 允许,因为 obj2 包含 obj1 的所有成员
// obj2 = obj1; // 不允许,因为 obj1 缺少 obj2 的 c 成员

对于函数类型:

let func1: (a: number) => void;
let func2: (a: number, b: string) => void;

func1 = func2; // 允许,因为 func2 可以接受 func1 能接受的所有参数
// func2 = func1; // 不允许,因为 func1 不能接受 func2 的第二个参数

名义类型与结构类型兼容性的交互

当我们在 TypeScript 中模拟名义类型时,需要注意与结构类型兼容性规则的交互。例如,在使用类来模拟名义类型时,虽然类的名称不同可以区分类型,但类内部的结构仍然遵循结构类型兼容性规则。

class Animal {
    constructor(public name: string) {}
}

class Dog extends Animal {
    constructor(name: string, public breed: string) {
        super(name);
    }
}

let animal: Animal;
let dog: Dog;

animal = dog; // 允许,因为 Dog 是 Animal 的子类,遵循结构类型兼容性
// dog = animal; // 不允许,因为 animal 缺少 dog 的 breed 成员

这里虽然 AnimalDog 是不同的类(名义类型不同),但由于继承关系,在结构类型兼容性上有特定的规则。

常见问题与解决方法

类型推断导致的名义类型混淆

在 TypeScript 中,类型推断有时可能会导致名义类型的效果被削弱。例如:

type UserId = { _tag: "UserId"; value: number };
type ProductId = { _tag: "ProductId"; value: number };

function getId(): { _tag: string; value: number } {
    // 假设这里根据某些逻辑返回 UserId 或 ProductId
    return { _tag: "UserId", value: 1 };
}

let id = getId();
// 这里 id 的类型是 { _tag: string; value: number },而不是 UserId 或 ProductId
// 导致无法利用名义类型的特性进行类型检查

解决方法是明确返回类型:

type UserId = { _tag: "UserId"; value: number };
type ProductId = { _tag: "ProductId"; value: number };

function getId(): UserId {
    return { _tag: "UserId", value: 1 };
}

let id = getId();
// 现在 id 的类型是 UserId,可以利用名义类型的特性

与第三方库的兼容性问题

当在项目中使用第三方库时,可能会遇到名义类型与第三方库类型系统不兼容的情况。例如,第三方库可能使用结构类型,而我们在项目中使用名义类型。

解决这种问题的一种方法是创建适配器层。例如,如果第三方库有一个函数接受特定结构的对象,而我们有一个名义类型的对象,我们可以创建一个函数将名义类型对象转换为符合第三方库要求的结构对象。

// 第三方库函数
function thirdPartyFunction(obj: { value: number }) {
    console.log(`Value from third party: ${obj.value}`);
}

class MyNumber {
    constructor(public value: number) {}
}

function adaptMyNumberToThirdParty(myNumber: MyNumber) {
    return { value: myNumber.value };
}

let myNumber = new MyNumber(10);
let adaptedObj = adaptMyNumberToThirdParty(myNumber);
thirdPartyFunction(adaptedObj);

通过这种适配器层,可以在名义类型和第三方库的类型系统之间建立桥梁,确保项目的正常运行。

未来TypeScript对名义类型支持的展望

随着 TypeScript 的发展,虽然其核心类型系统基于结构类型,但对于名义类型的支持可能会更加完善。未来可能会有更简洁的语法来定义和使用名义类型,进一步提高代码的可读性和可维护性。

例如,可能会引入专门的语法来明确声明名义类型,而不需要通过现有的模拟方式。这将使得开发人员在需要区分不同业务含义类型时更加方便,同时也能更好地与现有的结构类型系统协同工作。

另外,在类型检查和推断方面,可能会对名义类型有更智能的处理。比如,在类型推断过程中能够更好地识别名义类型的差异,避免因为类型推断而导致名义类型效果被削弱的问题。

总的来说,随着 TypeScript 在大型项目中的广泛应用,对名义类型支持的改进将有助于开发人员更高效地编写健壮的代码,满足日益复杂的业务需求。