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

TypeScript类型系统的基础概念与核心特性

2023-03-244.6k 阅读

类型系统的基本概念

在深入探讨TypeScript类型系统之前,我们先来了解一些基础概念。类型系统是编程语言的一个组成部分,它负责定义如何将数据分类为不同的类型,以及这些类型如何相互作用。

类型的定义与作用

类型可以看作是对数据的一种分类方式。在TypeScript中,常见的基本类型包括number(数字)、string(字符串)、boolean(布尔值)等。例如,let age: number = 30; 这里我们定义了一个变量age,它的类型是number,这意味着age只能被赋值为数字类型的值。

类型的主要作用是增加代码的可靠性和可维护性。通过在代码中明确指定变量的类型,编译器可以在编译阶段检测出许多类型不匹配的错误。比如,如果我们写成 let age: number = 'thirty';,TypeScript编译器会立即报错,提示字符串类型不能赋值给数字类型。这使得我们能够在开发过程中尽早发现潜在的错误,而不是等到代码在运行时才出现难以调试的类型错误。

静态类型与动态类型

编程语言的类型系统可以分为静态类型系统和动态类型系统。在静态类型语言中,变量的类型在编译时就已经确定,并且在变量的生命周期内不会改变。TypeScript就是一种静态类型语言,如上述age变量一旦被定义为number类型,后续就不能再赋值为其他类型(除非使用类型断言等特殊手段)。

而动态类型语言则是在运行时才确定变量的类型。例如JavaScript,在声明变量时无需指定类型,变量可以随时被赋予不同类型的值。如let value; value = 10; value = 'ten'; 在JavaScript中这样的代码是完全合法的,但在TypeScript中,如果我们想要实现类似的功能,就需要使用联合类型,这我们会在后面详细介绍。

TypeScript类型系统的核心特性

类型注解

类型注解是TypeScript中最基础的特性之一,它允许我们在变量声明时显式地指定变量的类型。语法上,类型注解紧跟在变量名之后,使用冒号:分隔。

// 数字类型注解
let num: number;
num = 10;

// 字符串类型注解
let str: string;
str = 'Hello, TypeScript';

// 布尔类型注解
let isDone: boolean;
isDone = true;

类型注解不仅可以用于变量声明,还可以用于函数参数和返回值的类型定义。

// 函数参数和返回值的类型注解
function add(a: number, b: number): number {
    return a + b;
}

let result = add(5, 3);

在上面的add函数中,我们明确指定了参数ab的类型为number,并且函数的返回值类型也是number。这样,如果调用add函数时传入的参数不是数字类型,TypeScript编译器就会报错。

类型推断

TypeScript具有强大的类型推断能力,这意味着在很多情况下,我们无需显式地为变量添加类型注解,TypeScript可以根据变量的赋值自动推断出其类型。

// 类型推断
let num = 10; // TypeScript推断num为number类型
let str = 'Hello'; // TypeScript推断str为string类型

function greet() {
    return 'Hello, world';
}
let message = greet(); // TypeScript推断message为string类型

在上面的代码中,我们没有为numstrmessage变量显式指定类型,但TypeScript通过它们的初始赋值成功推断出了相应的类型。

不过,需要注意的是,类型推断也有其局限性。例如,当变量在声明时没有初始值,或者在复杂的函数返回值场景下,类型推断可能无法得出准确的类型。

let value; // value的类型为any,因为没有初始值,TypeScript无法推断类型

function getValue() {
    let flag = Math.random() > 0.5;
    if (flag) {
        return 10;
    } else {
        return 'ten';
    }
}
let result = getValue(); // result的类型为number | string,因为函数返回值可能是不同类型

联合类型

联合类型允许一个变量具有多种类型。语法上,使用竖线|分隔不同的类型。

let value: number | string;
value = 10;
value = 'ten';

在上面的代码中,value变量可以被赋值为number类型或string类型的值。当使用联合类型时,我们只能访问联合类型中所有类型共有的属性和方法。

function printValue(value: number | string) {
    // 以下代码会报错,因为string类型没有toFixed方法
    // console.log(value.toFixed(2)); 
    if (typeof value === 'number') {
        console.log(value.toFixed(2));
    } else {
        console.log(value.toUpperCase());
    }
}

printValue(10);
printValue('hello');

printValue函数中,我们通过typeof操作符来判断value的实际类型,然后执行相应类型特有的操作,这样可以避免类型错误。

交叉类型

交叉类型用于将多个类型合并为一个类型,新类型包含了所有参与交叉的类型的特性。语法上,使用&符号连接不同的类型。

interface A {
    a: string;
}

interface B {
    b: number;
}

let ab: A & B = { a: 'hello', b: 10 };

在上面的代码中,ab变量的类型是A & B,这意味着它必须同时满足A接口和B接口的定义,即同时具有a属性(类型为string)和b属性(类型为number)。

交叉类型常用于需要同时具备多种类型特征的场景,比如将多个接口的功能合并到一个对象上。

接口

接口是TypeScript中用于定义对象类型的一种方式。它可以用来描述对象具有哪些属性以及这些属性的类型。

interface Person {
    name: string;
    age: number;
}

let tom: Person = { name: 'Tom', age: 25 };

在上述代码中,我们定义了一个Person接口,它描述了一个具有name(字符串类型)和age(数字类型)属性的对象。然后我们创建了一个tom对象,它符合Person接口的定义。

接口还可以定义可选属性和只读属性。

interface User {
    username: string;
    password: string;
    email?: string; // 可选属性
    readonly id: number; // 只读属性
}

let user: User = { username: 'john', password: '123456', id: 1 };
// user.id = 2; // 这行代码会报错,因为id是只读属性

User接口中,email属性是可选的,这意味着创建User类型的对象时可以不包含email属性。而id属性是只读的,一旦对象被创建,就不能再修改id的值。

接口还支持继承,通过继承可以复用其他接口的属性和方法。

interface Employee extends Person {
    job: string;
}

let alice: Employee = { name: 'Alice', age: 30, job: 'Engineer' };

在上述代码中,Employee接口继承自Person接口,因此Employee接口不仅具有job属性,还包含了Person接口的nameage属性。

类型别名

类型别名和接口类似,也用于定义类型。不同的是,类型别名可以用于定义任何类型,包括基本类型、联合类型、交叉类型等,而接口主要用于定义对象类型。

// 基本类型别名
type MyNumber = number;
let num: MyNumber = 10;

// 联合类型别名
type StringOrNumber = string | number;
let value: StringOrNumber = 'hello';
value = 20;

// 交叉类型别名
type AAndB = { a: string } & { b: number };
let ab: AAndB = { a: 'test', b: 10 };

类型别名还可以用于定义函数类型。

type AddFunction = (a: number, b: number) => number;
let add: AddFunction = function (a, b) {
    return a + b;
};

在上面的代码中,我们定义了一个AddFunction类型别名,它表示一个接受两个number类型参数并返回number类型值的函数。然后我们创建了一个add函数,它符合AddFunction类型。

泛型

泛型是TypeScript类型系统中非常强大的特性,它允许我们在定义函数、接口或类时使用类型参数,从而使这些组件能够适用于多种类型。

// 泛型函数
function identity<T>(arg: T): T {
    return arg;
}

let result1 = identity<number>(10);
let result2 = identity<string>('hello');

在上述identity函数中,<T>表示类型参数,T可以是任何类型。函数接受一个类型为T的参数arg,并返回相同类型T的值。通过在调用函数时指定<number><string>,我们可以让identity函数适用于不同的类型。

泛型还可以用于接口和类。

// 泛型接口
interface GenericIdentityFn<T> {
    (arg: T): T;
}

function identity<T>(arg: T): T {
    return arg;
}

let myIdentity: GenericIdentityFn<number> = identity;

// 泛型类
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;
};

GenericIdentityFn接口中,<T>作为类型参数,使得接口可以描述不同类型的函数。而在GenericNumber类中,<T>类型参数使得类可以适用于不同类型的数值操作,如上述代码中创建了一个适用于number类型的GenericNumber实例。

类型断言

类型断言用于告诉TypeScript编译器某个值的实际类型,即使编译器无法自动推断出该类型。语法上,有两种方式进行类型断言:<类型>值值 as 类型

let value: any = 'hello';
// 方式一
let length1: number = (<string>value).length;
// 方式二
let length2: number = (value as string).length;

在上述代码中,value的类型被声明为any,这意味着编译器不会对其进行类型检查。通过类型断言,我们告诉编译器value实际上是一个string类型,这样就可以访问string类型的length属性。

需要注意的是,类型断言只是一种手动指定类型的方式,使用不当可能会导致运行时错误。因此,只有在确实知道某个值的实际类型时才使用类型断言。

字面量类型

字面量类型是指具体的字面量值所对应的类型,如1'hello'true等。在TypeScript中,我们可以将变量声明为字面量类型。

let num: 1 = 1;
// num = 2; // 这行代码会报错,因为num的类型是1,只能赋值为1

let status: 'loading' | 'completed' | 'error' = 'loading';
// status = 'running'; // 这行代码会报错,因为status的类型只能是'loading'、'completed'或'error'

在上面的代码中,num变量的类型被限定为1,只能赋值为1。而status变量的类型是一个联合字面量类型,只能被赋值为'loading''completed''error'中的一个。

字面量类型常用于定义一些具有固定取值范围的变量,或者在函数参数中限定参数的取值。

function printStatus(status: 'loading' | 'completed' | 'error') {
    console.log(`Status: ${status}`);
}

printStatus('loading');
// printStatus('running'); // 这行代码会报错,因为参数类型不匹配

printStatus函数中,参数status的类型被限定为联合字面量类型,这样可以确保函数调用时传入的参数是合法的取值。

类型兼容性

在TypeScript中,类型兼容性是指一种类型是否可以赋值给另一种类型。TypeScript的类型兼容性基于结构子类型系统,即如果一个类型的结构(属性和方法)与另一个类型兼容,那么它们就是兼容的。

基本类型兼容性

基本类型之间的兼容性比较直接。例如,number类型可以赋值给number类型,string类型可以赋值给string类型。但是不同基本类型之间通常是不兼容的,如number类型不能赋值给string类型。

let num1: number = 10;
let num2: number = num1;

let str: string = 'hello';
// let num3: number = str; // 这行代码会报错,string类型不能赋值给number类型

对象类型兼容性

对于对象类型,TypeScript会比较它们的属性。如果目标类型的属性在源类型中都存在,并且类型兼容,那么源类型可以赋值给目标类型。

interface A {
    a: string;
}

interface B {
    a: string;
    b: number;
}

let a: A = { a: 'test' };
let b: B = a; // 这是允许的,因为A的属性在B中都存在且类型兼容

interface C {
    a: number;
}

// let c: C = a; // 这行代码会报错,因为a属性的类型不兼容

在上述代码中,A类型的对象可以赋值给B类型的对象,因为B类型包含了A类型的所有属性且类型兼容。但A类型的对象不能赋值给C类型的对象,因为a属性的类型不兼容。

函数类型兼容性

函数类型的兼容性比较复杂。在TypeScript中,对于函数参数,源函数的参数类型必须与目标函数的参数类型兼容,或者更宽松。而对于函数返回值,源函数的返回值类型必须与目标函数的返回值类型兼容,或者更严格。

let func1: (a: number) => number = function (a) {
    return a;
};

let func2: (a: number | string) => number = func1; // 这是允许的,因为func1的参数类型更严格

// let func3: (a: number) => string = func1; // 这行代码会报错,因为func1的返回值类型不兼容

在上面的代码中,func1函数可以赋值给func2函数,因为func2函数的参数类型number | stringfunc1函数的参数类型number更宽松。但func1函数不能赋值给func3函数,因为func1函数的返回值类型numberfunc3函数的返回值类型string不兼容。

类型守卫

类型守卫是一种运行时检查机制,用于在代码运行时确定一个值的类型。常见的类型守卫包括typeofinstanceof和自定义类型守卫函数。

typeof类型守卫

typeof操作符可以在运行时检查一个值的类型,并根据类型执行不同的逻辑。

function printValue(value: number | string) {
    if (typeof value === 'number') {
        console.log(value.toFixed(2));
    } else {
        console.log(value.toUpperCase());
    }
}

printValue(10);
printValue('hello');

在上述printValue函数中,通过typeof类型守卫,我们可以在运行时确定value的实际类型,并执行相应类型特有的操作。

instanceof类型守卫

instanceof用于检查一个对象是否是某个类的实例。

class Animal {}
class Dog extends Animal {}

function handleAnimal(animal: Animal) {
    if (animal instanceof Dog) {
        console.log('This is a dog');
    } else {
        console.log('This is some other animal');
    }
}

let dog = new Dog();
let animal = new Animal();

handleAnimal(dog);
handleAnimal(animal);

handleAnimal函数中,通过instanceof类型守卫,我们可以判断传入的animal对象是否是Dog类的实例,从而执行不同的逻辑。

自定义类型守卫函数

我们还可以定义自己的类型守卫函数。自定义类型守卫函数需要使用is关键字来声明返回值类型。

interface Bird {
    fly: () => void;
}

interface Fish {
    swim: () => void;
}

function isBird(animal: Bird | Fish): animal is Bird {
    return (animal as Bird).fly!== undefined;
}

function handleAnimal(animal: Bird | Fish) {
    if (isBird(animal)) {
        animal.fly();
    } else {
        animal.swim();
    }
}

let bird: Bird = { fly: function () { console.log('Flying'); } };
let fish: Fish = { swim: function () { console.log('Swimming'); } };

handleAnimal(bird);
handleAnimal(fish);

在上述代码中,isBird函数就是一个自定义类型守卫函数。通过这个函数,我们可以在handleAnimal函数中准确地判断animal的类型,并执行相应的操作。

高级类型

除了上述基础和核心特性外,TypeScript还提供了一些高级类型,用于处理更复杂的类型场景。

条件类型

条件类型允许我们根据类型的条件来选择不同的类型。语法上,使用T extends U? X : Y的形式,其中TUXY都是类型。如果T可以赋值给U,则结果为X类型,否则为Y类型。

type IsString<T> = T extends string? true : false;

type Result1 = IsString<string>; // true
type Result2 = IsString<number>; // false

在上面的代码中,IsString是一个条件类型,它判断传入的类型T是否为string类型。如果是,则返回true类型,否则返回false类型。

条件类型还可以用于更复杂的场景,比如映射类型中。

映射类型

映射类型允许我们基于现有的类型创建新的类型,通过对现有类型的属性进行映射和转换。

interface Person {
    name: string;
    age: number;
}

type ReadonlyPerson = {
    readonly [P in keyof Person]: Person[P];
};

let person: Person = { name: 'Tom', age: 25 };
let readonlyPerson: ReadonlyPerson = person;
// readonlyPerson.name = 'Jerry'; // 这行代码会报错,因为name属性是只读的

在上述代码中,我们通过映射类型ReadonlyPersonPerson接口的所有属性转换为只读属性。[P in keyof Person]表示遍历Person接口的所有属性名,Person[P]表示获取属性名P对应的属性类型。

索引类型

索引类型用于根据索引访问对象的属性类型。keyof操作符用于获取对象的所有属性名类型,T[K]用于获取对象T中属性名K对应的属性类型。

interface Person {
    name: string;
    age: number;
}

type PersonKeys = keyof Person; // 'name' | 'age'
type NameType = Person['name']; // string

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
    return obj[key];
}

let person: Person = { name: 'Tom', age: 25 };
let name = getProperty(person, 'name');

在上面的代码中,keyof Person获取了Person接口的所有属性名类型,Person['name']获取了name属性的类型。getProperty函数使用索引类型来实现根据属性名获取对象属性值的功能。

逆变与协变

在TypeScript的类型系统中,逆变和协变是关于类型之间关系在函数参数和返回值类型上的体现。

协变:对于函数返回值类型,子类型可以赋值给父类型,这就是协变。例如,如果有一个函数返回Dog类型(DogAnimal的子类型),那么这个函数的返回值可以被赋值给期望Animal类型的地方。

class Animal {}
class Dog extends Animal {}

function getDog(): Dog {
    return new Dog();
}

let animal: Animal = getDog(); // 这是允许的,因为Dog是Animal的子类型,返回值协变

逆变:对于函数参数类型,父类型可以赋值给子类型,这就是逆变。例如,如果有一个函数接受Animal类型的参数,那么可以传入Dog类型(DogAnimal的子类型)的参数。

function feedAnimal(animal: Animal) {
    console.log('Feeding an animal');
}

function feedDog(dog: Dog) {
    console.log('Feeding a dog');
}

let feedFunction: (animal: Animal) => void = feedDog; // 这是允许的,因为函数参数逆变

理解逆变与协变对于处理复杂的函数类型关系非常重要,特别是在泛型和类型兼容性的场景中。

通过深入了解TypeScript类型系统的这些基础概念与核心特性,我们能够编写出更健壮、可维护的前端代码,充分发挥TypeScript在类型安全方面的优势,提升开发效率和代码质量。无论是简单的变量类型定义,还是复杂的泛型、高级类型的运用,都需要我们在实际开发中不断实践和积累经验,以更好地驾驭TypeScript这一强大的前端开发工具。