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

TypeScript 类型系统介绍:静态类型与复合类型解析

2023-08-204.5k 阅读

静态类型基础概念

在前端开发的历史长河中,JavaScript 一直以其动态类型的灵活性而被广泛使用。动态类型意味着变量的类型在运行时才被确定,这使得代码编写快速且灵活,但也带来了一些问题,比如在大型项目中,类型错误可能在运行时才暴露出来,调试成本较高。

TypeScript 引入了静态类型系统,静态类型意味着变量的类型在编译时就被确定。这就好比在盖房子之前就确定好每一块砖的大小和用途,而不是在盖的过程中才去想这块砖该怎么用。例如:

let num: number = 10;
num = 'ten'; // 这里会报错,因为类型不匹配,在编译阶段就能发现

在上述代码中,我们定义了一个变量 num 并指定其类型为 number。如果后续尝试将一个字符串类型的值赋给它,TypeScript 编译器会立即报错,提示类型不匹配。这种在编译阶段就能捕获类型错误的特性,大大提高了代码的可靠性和可维护性。

基本静态类型

  1. 数字类型(number) 在 TypeScript 中,number 类型表示所有的数字,包括整数和浮点数。
let age: number = 25;
let pi: number = 3.14;
  1. 字符串类型(string) string 类型用于表示文本数据。可以使用单引号、双引号或模板字符串来定义字符串。
let name: string = 'John';
let greeting: string = "Hello, " + name;
let message: string = `Welcome, ${name}`;
  1. 布尔类型(boolean) boolean 类型只有两个值:truefalse,常用于逻辑判断。
let isDone: boolean = false;
if (isDone) {
    console.log('任务完成');
} else {
    console.log('任务未完成');
}
  1. null 和 undefined nullundefined 在 TypeScript 中有特殊的类型含义。null 表示空值,undefined 表示未定义。在严格模式下,它们各自是一种独立的类型。
let n: null = null;
let u: undefined = undefined;
  1. void 类型 void 类型通常用于表示函数没有返回值。
function logMessage(message: string): void {
    console.log(message);
}
  1. never 类型 never 类型表示那些永远不会有返回值的函数,或者是那些永远不会执行到终点的代码块。例如,一个抛出异常或进入无限循环的函数。
function throwError(message: string): never {
    throw new Error(message);
}
function infiniteLoop(): never {
    while (true) {}
}
  1. any 类型 any 类型表示任意类型。当你不确定一个值的类型,或者你想关闭类型检查时,可以使用 any 类型。但过度使用 any 类型会失去 TypeScript 静态类型检查的优势。
let value: any = 'initial value';
value = 10; // 不会报错,因为是 any 类型

类型推断

TypeScript 强大的特性之一是类型推断。当你没有明确指定变量类型时,TypeScript 编译器会根据变量的赋值来推断其类型。

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

在函数参数和返回值的类型推断方面,TypeScript 同样表现出色。

function add(a, b) {
    return a + b;
}
let result = add(5, 3); // TypeScript 推断 result 为 number 类型,因为 add 函数返回数字

但在某些复杂情况下,类型推断可能会不准确,这时就需要手动指定类型来确保代码的正确性。

复合类型概述

复合类型是由基本类型组合而成的类型,它使得我们能够更精确地描述复杂的数据结构。复合类型主要包括数组类型、元组类型、对象类型、联合类型和交叉类型等。这些类型为前端开发者提供了强大的工具,能够更好地组织和管理数据。

数组类型

  1. 定义数组类型 在 TypeScript 中,有两种方式定义数组类型。一种是在元素类型后面加上 [],另一种是使用 Array<元素类型> 的语法。
let numbers: number[] = [1, 2, 3];
let strings: Array<string> = ['a', 'b', 'c'];
  1. 只读数组 如果你希望数组一旦初始化就不能被修改,可以使用 readonly 关键字。
let readonlyNumbers: readonly number[] = [1, 2, 3];
// readonlyNumbers[0] = 4; // 这里会报错,因为是只读数组

元组类型

元组类型允许你定义一个固定长度且元素类型已知的数组,但每个元素的类型可以不同。

let user: [string, number] = ['John', 25];
// 访问元组元素
let name: string = user[0];
let age: number = user[1];

元组类型在需要按顺序返回多个不同类型的值时非常有用,比如从函数中返回坐标点。

function getPoint(): [number, number] {
    return [10, 20];
}
let point: [number, number] = getPoint();

对象类型

  1. 定义对象类型 对象类型用于描述具有特定属性和属性类型的对象。可以使用接口(interface)或类型别名(type alias)来定义对象类型。
// 使用接口定义对象类型
interface User {
    name: string;
    age: number;
}
let user1: User = { name: 'Alice', age: 30 };
// 使用类型别名定义对象类型
type Product = {
    title: string;
    price: number;
};
let product1: Product = { title: 'Book', price: 25 };
  1. 可选属性和只读属性 对象类型可以有可选属性,使用 ? 表示。只读属性使用 readonly 关键字。
interface Settings {
    theme?: string;
    readonly version: number;
}
let settings: Settings = { version: 1 };
// settings.version = 2; // 这里会报错,因为 version 是只读属性
  1. 索引签名 当你不确定对象会有哪些属性,但知道属性的类型时,可以使用索引签名。
interface StringMap {
    [key: string]: string;
}
let map: StringMap = { key1: 'value1', key2: 'value2' };

联合类型

联合类型表示一个值可以是多种类型中的一种。使用 | 来分隔不同的类型。

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

在函数参数中使用联合类型可以接受多种类型的参数。

function printValue(val: string | number) {
    console.log(val);
}
printValue('text');
printValue(123);

但在使用联合类型的值时,需要注意类型保护,以确保在运行时正确处理不同类型的值。

交叉类型

交叉类型是将多个类型合并为一个类型,它包含了所有类型的特性。使用 & 来表示交叉类型。

interface A {
    a: string;
}
interface B {
    b: number;
}
let ab: A & B = { a: 'value', b: 10 };

交叉类型常用于扩展现有类型,或者表示一个对象同时满足多种类型的要求。

类型别名与接口的区别

  1. 语法结构
    • 类型别名:使用 type 关键字定义,语法更加灵活,可以用于基本类型、联合类型、交叉类型等多种情况。例如:
type ID = number | string;
  • 接口:使用 interface 关键字定义,主要用于描述对象类型的结构。例如:
interface User {
    name: string;
    age: number;
}
  1. 可扩展性
    • 接口:可以通过声明合并进行扩展,即多次定义相同名称的接口,TypeScript 会将它们合并为一个接口。例如:
interface Point {
    x: number;
}
interface Point {
    y: number;
}
let point: Point = { x: 1, y: 2 };
  • 类型别名:不能通过声明合并扩展,如果定义了相同名称的类型别名,会报错。但类型别名可以通过交叉类型来达到类似扩展的效果。例如:
type BaseType = { a: string };
type ExtendedType = BaseType & { b: number };
  1. 使用场景
    • 接口:更适合用于描述对象的形状,尤其是在定义对象的公共结构和契约方面,在面向对象编程中使用较多。比如定义类实现的接口。
    • 类型别名:除了对象类型,还适用于基本类型、联合类型、交叉类型等多种情况,更侧重于为复杂类型创建简洁的别名,提高代码的可读性。

高级类型特性

  1. 条件类型 条件类型允许根据类型的条件来选择不同的类型。语法为 T extends U? X : Y,表示如果类型 T 可以赋值给类型 U,则返回类型 X,否则返回类型 Y
type IsString<T> = T extends string? true : false;
type StringCheck = IsString<string>; // true
type NumberCheck = IsString<number>; // false

条件类型在处理泛型和类型转换时非常有用。 2. 映射类型 映射类型允许你基于现有的类型创建新的类型,通过对现有类型的属性进行遍历和转换。

interface User {
    name: string;
    age: number;
}
type ReadonlyUser = {
    readonly [P in keyof User]: User[P];
};
let readonlyUser: ReadonlyUser = { name: 'Bob', age: 22 };
// readonlyUser.name = 'New Name'; // 报错,因为属性是只读的
  1. 索引类型 索引类型主要用于通过索引来访问对象的属性类型。keyof 操作符用于获取对象的键类型,T[K] 用于获取对象 T 中键 K 的值类型。
interface Product {
    name: string;
    price: number;
}
type ProductKeys = keyof Product; // 'name' | 'price'
type ProductNameType = Product['name']; // string

泛型

  1. 泛型基础概念 泛型是 TypeScript 中非常强大的特性,它允许我们在定义函数、接口或类时不指定具体的类型,而是在使用时再指定类型。这样可以提高代码的复用性。
function identity<T>(arg: T): T {
    return arg;
}
let result1 = identity<number>(10);
let result2 = identity<string>('hello');

在上述代码中,T 是类型参数,它可以代表任何类型。在调用 identity 函数时,通过 <number><string> 来指定 T 的具体类型。 2. 泛型函数 泛型函数可以有多个类型参数。

function pair<T, U>(first: T, second: U): [T, U] {
    return [first, second];
}
let pair1 = pair<number, string>(1, 'one');
  1. 泛型接口 可以定义泛型接口,使接口的类型参数化。
interface Container<T> {
    value: T;
}
let container: Container<number> = { value: 42 };
  1. 泛型类 泛型类在类的定义中使用类型参数。
class Stack<T> {
    private items: T[] = [];
    push(item: T) {
        this.items.push(item);
    }
    pop(): T | undefined {
        return this.items.pop();
    }
}
let stack = new Stack<number>();
stack.push(1);
let popped = stack.pop();

类型兼容性

  1. 基本类型兼容性 在 TypeScript 中,基本类型之间的兼容性比较直观。例如,number 类型与 number 类型兼容,string 类型与 string 类型兼容。但不同基本类型之间通常不兼容,比如 numberstring 不兼容。
let num1: number = 10;
let num2: number = num1; // 兼容
let str: string = 'hello';
// num1 = str; // 报错,类型不兼容
  1. 对象类型兼容性 对象类型的兼容性是基于结构的。如果一个对象类型 A 的所有属性都能在另一个对象类型 B 中找到,并且类型兼容,那么 A 兼容于 B
interface A {
    a: number;
}
interface B {
    a: number;
    b: string;
}
let aObj: A = { a: 1 };
let bObj: B = aObj; // 兼容,因为 A 的属性在 B 中都存在
  1. 函数类型兼容性 函数类型的兼容性比较复杂。对于参数,参数少的函数兼容参数多的函数,因为可以传递更少的参数。对于返回值,返回值类型更具体(子类型)的函数兼容返回值类型更宽泛(父类型)的函数。
let func1: (a: number) => number = (a) => a * 2;
let func2: (a: number, b: string) => number = func1; // 兼容,参数少的兼容参数多的
function func3(): string {
    return 'hello';
}
function func4(): object {
    return func3(); // 兼容,返回值类型更具体的兼容返回值类型更宽泛的
}

类型断言

类型断言是一种告诉编译器“相信我,我知道自己在做什么”的方式,用于手动指定一个值的类型。有两种语法:<类型>值值 as 类型

let value: any = 'hello';
let length1: number = (<string>value).length;
let length2: number = (value as string).length;

在使用 as 语法时,更适合在 JSX 中使用,因为 <类型> 语法在 JSX 中可能会被误认为是标签。但要注意,类型断言应该谨慎使用,因为如果断言错误,可能会导致运行时错误。

总结静态类型与复合类型在前端开发中的应用

静态类型和复合类型在前端开发中为开发者提供了更强大的类型控制能力。静态类型通过在编译阶段捕获类型错误,提高了代码的可靠性,减少了运行时错误的发生。复合类型则使得我们能够更精确地描述复杂的数据结构,如对象、数组、联合类型等,从而更好地组织和管理前端应用中的数据。无论是构建小型项目还是大型企业级应用,TypeScript 的类型系统都能帮助开发者编写更健壮、可维护的代码,提升开发效率和代码质量。在实际开发中,合理运用静态类型和复合类型的各种特性,结合泛型、类型断言等高级功能,能够让前端代码更加严谨和高效。