TypeScript中的类型用法和实践总结

930 阅读18分钟

前言

TypeScript是一种由微软开发的静态类型语言,它为JavaScript代码添加了类型注解和其他实用的语言特性。主要目标是帮助开发人员在编写大型应用程序时减少错误,并提高代码的可维护性和可读性。与JavaScript相比,TypeScript具有更强的类型检查和编译时错误捕获能力,使得代码更加稳健和可靠。

结合笔者已有的TS项目实践,在本文中,我将探讨和总结TypeScript的类型用法和高级概念,从而帮助你更好地理解和使用上这一语言。无论你是初学者还是有经验的开发者,笔者希望通过这个文章能够帮助你更好地掌握TypeScript,并在实际的项目中运用它创造更好的代码。

TypeScript解决了什么问题

  1. 类型错误:JavaScript是一种动态类型语言,因此在编写复杂代码时难以避免类型错误。TypeScript通过静态类型检查提供了更加严格的类型约束,可以在编译时检测出许多常见的类型错误;
  2. 代码提示:可以不用打开网页查看API文档,直接在VS Code中查看接口定义和参数使用,提升编码效率;
  3. 难以维护和重构:随着项目规模的扩大,JavaScript代码变得越来越难以维护和重构。TypeScript支持interface、命名空间和模块等概念,可以帮助开发者组织更好的代码结构,并提供更好的可读性和可维护性。举个例子:定义interface数据类型,后来我发现这个地方某个字段错了,用 ts 的时候,我只需要修改下他的类型,相应的,所有应到这个类型的地方都会显示错误,这样我就可以一个不漏的把所有用到的地方修改了,因为不修改的话编译不过。没有用 ts 的时候,首先我得全局查询接口名字看看哪里用到了,其次还要全局查询相应的属性名字,看看那里用到了,这些都不会有提示,而且可以直接编译通过的,到运行时才发现这个报错。个人理解ts初衷是为了方便开发,可以辅助书写可维护性可读性高的代码。
  4. 无法良好地支持面向对象编程:JavaScript虽然支持面向对象编程,但其原型继承模型和弱类型限制使得面向对象编程模式难以实现。TypeScript提供了类、继承、封装等常见的面向对象编程特性,并提供更强的类型检查支持。

虽然TS给端开发者提供了比较舒服的体验,但从本质上讲,TypeScript 把复杂性从端开发者那转移给了库开发者,最终显著增加了库开发流程侧的工作负担。端开发者可太幸福了,TypeScript 给他们准备了完备的说明文档和代码提示,把劳动力解放出来投入到更能创造价值的地方。但在库开发者这边,可用的素材却很少。我能找到的最接近库开发需求的内容,主要集中在类型操作上面,因为它需要写d.ts描述对外接口文件,降低库开发者的工作效率。

TypeScript有哪些类型

  1. JS已有类型

    • 原始类型: number、string、boolean、null、undefined、symbol

    • 对象类型:object(数组、对象、函数等)

  2. TS新增类型

    • 联合类型、自定义类型(类型别名)、交叉类型、接口interface、元组、枚举、泛型、高级类型、unknown、never、void、any 等

元组类型

当我们需要定义一个固定长度,每个位置上数据类型都不同的数组时,可以使用元组类型。例如:

let myTuple: [string, number, boolean];
myTuple = ['hello', 123, true];

console.log(myTuple[0]); // 输出 'hello'
console.log(myTuple[1]); // 输出 123
console.log(myTuple[2]); // 输出 true

在上面的例子中,myTuple 是一个元组类型,包含了三个元素:第一个元素是字符串类型,第二个元素是数字类型,第三个元素是布尔类型。我们在赋值时按照顺序依次填充每个元素的值,并且可以通过索引访问每个元素的值。

枚举类型

当我们需要定义一组命名的常量或状态常量时,可以使用枚举类型。例如:

enum Color {
  Red,
  Green,
  Blue,
}

let myColor: Color = Color.Green;

if (myColor === Color.Red) {
  console.log('选中了红色');
} else if (myColor === Color.Green) {
  console.log('选中了绿色');
} else {
  console.log('选中了蓝色');
}

枚举类型的默认值是从0开始递增的数字,也可以手动指定每个常量的值,例如:

enum Status {
  Draft = '00',
  Restore = '01',
  Submited = '02',
  Receipt = '03',
}

枚举类型的值可以是字符串或数字类型,也可以是混合类型。

泛型类型

泛型是TypeScript当中必知必会的一个属性,在很多的时候,类型推导在开始时很难进行推导。相比于使用 any 类型,使用泛型来创建可复用性更好,因为泛型会保留参数类型,能够非常灵活的对一个类型进行定义和延伸推导。

软件工程中,我们不仅要创建一致的定义良好的 API,同时也要考虑可重用性。 组件不仅能够支持当前的数据类型,同时也能支持未来的数据类型,这在创建大型系统时为你提供了十分灵活的功能。

使用场景

  1. 函数参数类型约束:泛型类型可以用于对函数的参数类型进行约束,从而避免了传入非预期类型的参数。
  2. 多态代码库:泛型类型可以用于创建支持多种数据类型的通用代码库,从而增加代码的复用性和灵活性。
  3. 类型转换工具:泛型类型可以用于创建类型转换工具,将一个数据类型转换为另一个数据类型,从而方便地处理数据类型不一致的情况。
  4. 静态类型分析:通过使用泛型类型,编译器可以更好地进行静态类型分析,从而提高代码的可读性和可维护性。

泛型类型是 TypeScript 中非常重要的语言特性,它可以大幅度提高代码的可读性、可维护性和复用性。

泛型例子

函数参数类型约束
function identity<T>(arg: T): T {
  return arg;
}

let output = identity<string>("hello world");
console.log(output); // 输出 "hello world"
useState
type Person = {
  name: string
  age: number
}
function App() {
  const [person, setPerson] = useState<Person>({ name: '', age: 0 })
  const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value } = event.target
    setPerson({ ...person, name: value })
  }
  return (
    <div>
      <input type="text" name="name" value={person.name} onChange={handleInputChange} />
    </div>
  )
}
表单事件
interface IComponentProps {
  // 表单事件, 泛型参数是event.target的类型
  onChange?: React.FormEventHandler<HTMLInputElement>
}
事件处理函数
type EventHandler<E extends React.SyntheticEvent<any>> = {
  bivarianceHack(event: E): void
}['bivarianceHack']
type FocusEventHandler<T = Element> = EventHandler<React.FocusEvent<T>>
type KeyboardEventHandler<T = Element> = EventHandler<React.KeyboardEvent<T>>

bivarianceHack 为事件处理函数的类型定义,函数接收一个 event 对象,并且其类型为接收到的泛型变量 E 的类型, 返回值为 void

Promise对象

在做异步操作时我们经常使用 async 函数,函数调用时会 return 一个 Promise 对象,可以使用 then 方法添加回调函数。Promise 是一个泛型类型,T 泛型变量用于确定 then 方法时接收的第一个回调函数的参数类型。

type IResponse<T> = {
  message: string
  result: T
  success: boolean
}
async function getResponse(): Promise<IResponse<number[]>> {
  return {
    message: '获取成功',
    result: [1, 2, 3],
    success: true,
  }
}
getResponse().then(response => {
  console.log(response.result)
})

首先声明 IResponse 的泛型接口用于定义 response 的类型,通过 T 泛型变量来确定 result 的类型。然后声明了一个 异步函数 getResponse 并且将函数返回值的类型定义为Promise<IResponse<number[]>> 。最后调用 getResponse 方法会返回一个 promise 类型,通过 then 调用,此时 then 方法接收的第一个回调函数的参数 response 的类型为,{ message: string, result: number[], success: boolean}

泛型参数组件

下面这个组件的 name 属性都是指定了传参格式,如果想不指定,而是想通过传入参数的类型去推导实际类型,这就要用到泛型。

const TestB = ({ name, name2 }: { name: string; name2?: string }) => {
  return (
    <div>
      TestB--{name}
      {name2}
    </div>
  )
}

如果需要外部传入参数类型,只需:

type Props<T> = {
  name: T
  name2?: T
}
const TestC: <T>(props: Props<T>) => React.ReactElement = ({ name, name2 }) => {
  return (
    <div>
      TestB--{name}
      {name2}
    </div>
  )
}
const TestD = () => {
  return (
    <div>
      <TestC<string> name="123" />
    </div>
  )
}
函数,接口或者类需要作用到很多类型的时候

当我们需要一个 id 函数,函数的参数可以是任何值,返回值就是将参数原样返回,并且其只能接受一个参数,在 js 时代我们会很轻易地甩出一行:

const id = arg => arg

由于其可以接受任意值,也就是说我们的函数的入参和返回值都应该可以是任意类型,如果不使用泛型,我们只能重复的进行定义

type idBoolean = (arg: boolean) => boolean
type idNumber = (arg: number) => number
type idString = (arg: string) => string

如果使用泛型,我们只需要:

function id<T>(arg: T): T {
  return arg
}
const id1: <T>(arg: T) => T = arg => {
  return arg
}
interface和type
type Generics<T> = {
    name: string
    age: number
    sex: T
}
interface Generics<T> {
    name: string
    age: number
    sex: T
}
泛型类
class Person<T> {
    private sex: T;
    constructor(readonly type: T) { 
        this.sex = type; 
    }
}
const person = new Person<'男'>('女')
检查对象上的键是否存在
interface Person {
  name: string;
  age: number;
  location: string;
}
type K1 = keyof Person; // "name" | "age" | "location"
type K2 = keyof Person[];  // number | "length" | "push" | "concat" | ...
type K3 = keyof { [x: string]: Person };  // string | number

通过 keyof 操作符,我们就可以获取指定类型的所有键,之后我们可以结合 extends 约束,即限制输入的属性名包含在 keyof 返回的联合类型中。具体的使用方式如下:

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

在以上的 getProperty 函数中,我们通过 K extends keyof T 确保参数 key 一定是对象中含有的键,这样就不会发生运行时错误。这是一个类型安全的解决方案,与简单调用 let value = obj[key]; 不同。

下面我们来看一下如何使用 getProperty 函数:

enum Difficulty {
  Easy,
  Intermediate,
  Hard
}
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}
let tsInfo = {
   name: "Typescript",
   supersetOf: "Javascript",
   difficulty: Difficulty.Intermediate
}
let difficulty: Difficulty = getProperty(tsInfo, 'difficulty'); // OK
let supersetOf: string = getProperty(tsInfo, 'superset_of'); // Error

在以上示例中,对于 getProperty(tsInfo, 'superset_of') 这个表达式,TypeScript 编译器会提示以下错误信息:

Argument of type '"superset_of"' is not assignable to parameter of type 
'"difficulty" | "name" | "supersetOf"'

很明显通过使用泛型约束,在编译阶段我们就可以提前发现错误,大大提高了程序的健壮性和稳定性。

unknown类型

unknown类型是一种表示未知值的类型。它类似于any类型,但是更加类型安全。

1.函数参数类型:当函数参数的类型无法确定时,可以使用unknown类型作为参数类型。例如:

function parseJSON(jsonString: string): unknown {
  return JSON.parse(jsonString);
} 

2.类型断言:当需要将一个unknown类型的值转换成其他类型时,可以使用类型断言。例如:

const userInput: unknown = getUserInput();
const userName: string = userInput as string;

3.类型守卫:通过类型守卫可以缩小unknown类型的范围,从而使其更具体化。例如:

function isString(value: unknown): value is string {
   return typeof value === 'string';
}
function processValue(value: unknown) {
  if (isString(value)) {
    // value is now narrowed to string
    console.log(value.toUpperCase());
  } else {
    console.log('Value is not a string');
  }
}

any类型

当我们在TypeScript中使用 any 类型时,我们可以将变量的类型标记为任何类型。这意味着该变量可以存储任何类型的值,从字符串到数字,甚至是对象和函数。

以下是一些 any 类型的示例:

// 可以被赋值为任何类型的值
let anything: any = 'hello world';
anything = 42;
anything = true;
anything = { name: 'John', age: 30 };
anything = function() { return 'hello world'; };
// 可以接受任何类型的参数和返回值的函数
function doSomething(value: any): any {
  // 做一些操作...
  return value;
}
// 可以包含任何类型的值的数组
const array: any[] = [1, 'two', { three: 3 }];

// 可以包含任何类型键和值的对象
const object: {[key: string]: any} = {
  name: 'John',
  age: 30,
  favoriteColor: ['blue', 42],
  address: { street: '123 Main St', city: 'Anytown' },
};

请注意,使用 any 类型会使代码更灵活,但也会失去 TypeScript 的类型检查功能,因此应该谨慎使用。

void类型

void 类型表示一个函数没有返回值。当函数执行完毕后,它不会返回任何东西。以下是一些 void 类型的示例:

// 函数没有返回值
function logMessage(message: string): void {
  console.log(message);
}
// 函数执行完毕后没有返回任何东西
function doSomething(): void {
  // 做一些操作...
}
// 可以赋值为 undefined 或 null 的变量
let nothing: void = undefined;
nothing = null;
// 回调函数通常使用 void 类型作为返回类型
function fetchData(callback: (data: any) => void): void {
  // 假设这里有一些异步数据获取的逻辑...
  const data = { name: 'John', age: 30 };
  callback(data);
}

请注意void 类型只能用于表示函数没有返回值。如果您要表示一个变量可以为空,请使用 nullundefined 类型。

never类型

never 类型表示一个函数永远不会返回结果。通常,这意味着该函数将抛出一个异常或进入一个无限循环,从而使程序终止。

以下是一些 never 类型的示例:

// 函数总是抛出一个错误
function throwError(message: string): never {
  throw new Error(message);
}
// 函数总是会进入一个无限循环
function infiniteLoop(): never {
  while (true) {
    // 做一些操作...
  }
}
// 可以用作其他类型的子类型
function checkNever(): never {
  return throwError('Error');
}
const value: string = checkNever(); // 这里会报编译错误
// switch/case 语句中使用
type EventType = 'click' | 'keydown' | 'keyup';
function handleEvent(eventType: EventType, event: Event): void {
  switch (eventType) {
    case 'click':
      console.log('Clicked!');
      break;
    case 'keydown':
    console.log('Keydown!');
      break;
    case 'keyup':
      console.log('Keyup!');
      break;
    default:
      const error: never = eventType; // 这里会报编译错误,因为没有 default 分支
      throwError(`Invalid event type: ${error}`);
  }
}

请注意never 类型只有在函数永远不会返回任何东西时才应该使用。如果您要表示一个变量可能具有多种不同的类型,请使用联合类型。

联合类型

联合类型用于指定一个变量可以具有多种不同的类型。使用 | 操作符将这些类型组合在一起,从而形成一个联合类型。

// 可以是字符串或数字的变量
let value: string | number = 'hello';
value = 42;
// 可以是不同类别的对象的数组
const items: (string[] | number[])[] = [
  ['a', 'b', 'c'],
  [1, 2, 3],
];
// 可以是两个函数之一的函数
function processInput(input: string | number, callback: (result: string | number) => void): void {
  // 做一些操作...
  const result = input.toString();
  callback(result);
}
// 可以是其他类型之一的属性的对象
type Person = {
  name: string;
  age: number;
} | {
  username: string;
  password: string;
};
// 可以是任何类型的参数和返回值的函数
function doSomething(value: any): any {
  // 做一些操作...
  return value;
}

使用联合类型会增加代码的灵活性,但也可能导致代码更难以理解和维护。因此,应该谨慎使用联合类型,并始终尽可能地使用更具体的类型。

交叉类型

交叉类型用于将多个类型组合成一个类型。使用 & 操作符将这些类型组合在一起,从而形成一个交叉类型。以下是一些交叉类型的示例:

// 可以具有 name 和 age 属性的对象
type Person = {
  name: string;
  age: number;
};
// 可以具有 email 和 phone 属性的对象
type ContactInfo = {
  email: string;
  phone: number;
};
// 具有 Person 和 ContactInfo 属性的对象
type Customer = Person & ContactInfo;
// 函数参数和返回值都需要具有相同的属性
function mergeObjects<T, U>(obj1: T, obj2: U): T & U {
  return { ...obj1, ...obj2 };
}
// 具有两个不同类别的对象的所有属性
type Car = {
  make: string;
  model: string;
} & {
  year: number;
  color: string;
};
const customer: Customer = {
  name: 'John Doe',
  age: 30,
  email: 'john.doe@example.com',
  phone: 1234567890,
};
const car: Car = {
  make: 'Toyota',
  model: 'Camry',
  year: 2020,
  color: 'silver',
};
const mergedObject = mergeObjects(customer, car);
console.log(mergedObject); // 输出具有所有属性的合并对象

交叉类型可以增加代码的灵活性,但也可能导致类型变得复杂。因此,应该谨慎使用交叉类型,并始终尽可能地使用更简单的类型。

类型别名

类型别名用于给一个类型起一个新的名称。使用 type 关键字创建类型别名,然后可以将其用作其他类型的简写。以下是一些类型别名的示例:

// 具有 name 和 age 属性的对象
type Person = {
  name: string;
  age: number;
};
// 具有 email 和 phone 属性的对象
type ContactInfo = {
  email: string;
  phone: number;
};
// 具有 Person 和 ContactInfo 属性的对象
type Customer = Person & ContactInfo;
// 函数参数和返回值都需要具有相同的属性
type MergeObjects<T, U> = (obj1: T, obj2: U) => T & U;

使用类型别名可以使代码更易读,更易维护,并减少重复的代码。

常用的高级类型

有许多高级的类型可以用来增强代码的表达能力和灵活性。以下是一些常见的高级类型及其示例:

条件类型

条件类型表示一个类型在满足某些条件时应该是什么类型。例如,以下代码将根据 T 是否为 string 类型来确定返回值的类型:

type StringOrNumber<T> = T extends string ? string : number;
const result1: StringOrNumber<string> = 'hello'; // 返回 string
const result2: StringOrNumber<number> = 42; // 返回 number

映射类型

映射类型允许将一个类型的所有属性转换为另一个类型。例如,以下代码将把 Person 类型中的所有属性都变成可选的:

type Person = {
  name: string;
  age: number;
};
type OptionalPerson<T> = {
  [K in keyof T]?: T[K];
};
const person: OptionalPerson<Person> = { name: 'John Doe' };

Omit类型

剩余类型表示一个类型中除了已知属性之外的所有属性。例如,以下代码将从 Person 类型中排除 name 属性:

type Person = {
  name: string;
  age: number;
};
type RemainingPerson<T> = Omit<T, 'name'>;
const remainingPerson: RemainingPerson<Person> = { age: 30 };

Partial类型

Partial<T> 可以用来把类型 T 中所有属性转化为可选的:

type Partial<T> = { [P in keyof T]?: T[P] }

如果需要深 Partial 我们可以通过泛型递归来实现:

type DeepPartial<T> = T extends Function
  ? T
  : T extends object
  ? { [P in keyof T]?: DeepPartial<T[P]> }
  : T
type PartialedWindow = DeepPartial<Window>

Record类型

Record<K, V> 可以用来定义一个对象类型,该对象类型包含键类型为 K,值类型为 V 的一组属性。

也可用于动态生成类型,比如根据一个数组生成一个对象:

const fruits: string[] = ['apple', 'banana', 'orange'];
type FruitRecord = Record<typeof fruits[number], { color: string }>;
const fruitColors: FruitRecord = {
  apple: { color: 'red' },
  banana: { color: 'yellow' },
  orange: { color: 'orange' },
}

Pick类型

Pick<T, K extends keyof T> 的作用是将某个类型中的子属性挑出来,变成包含这个类型部分属性的子类型。

interface Todo {
  title: string;
  description: string;
  completed: boolean;
}
type TodoPreview = Pick<Todo, "title" | "completed">;
const todo: TodoPreview = {
  title: "Clean room",
  completed: false
};

Exclude类型

Exclude<T, U> 的作用是将某个类型中属于另一个的类型移除掉。

type T0 = Exclude<"a" | "b" | "c", "a">; // "b" | "c"
type T1 = Exclude<"a" | "b" | "c", "a" | "b">; // "c"
type T2 = Exclude<string | number | (() => void), Function>; // string | number

ReturnType类型

ReturnType<T> 的作用是用于获取函数 T 的返回类型。

type T0 = ReturnType<() => string>; // string
type T1 = ReturnType<(s: string) => void>; // void
type T2 = ReturnType<<T>() => T>; // {}
type T3 = ReturnType<<T extends U, U extends number[]>() => T>; // number[]
type T4 = ReturnType<any>; // any
type T5 = ReturnType<never>; // any
type T6 = ReturnType<string>; // Error
type T7 = ReturnType<Function>; // Error

TypeScript中其他常用的特性

TypeScript中,有一些好用的特性功能对于日常开发来说是比较常见的。

Readonly

Readonly 是 TypeScript 中的一个类型修饰符,可以用来将属性设置为只读,即不能被修改。使用 Readonly 修饰的属性只能在对象初始化时被赋值,并且在后续的操作中不能被修改。

  1. 将对象中的所有属性都设置为只读:

    interface Person {
      readonly name: string;
      readonly age: number;
    }
    const person: Person = { name: 'Alice', age: 30 };
    person.name = 'Bob'; // Error: Cannot assign to 'name' because it is a read-only property.
    
  2. 将对象中的某个属性设置为只读:

    interface Person {
      name: string;
      readonly age: number;
    }
    const person: Person = { name: 'Alice', age: 30 };
    person.age = 31; // Error: Cannot assign to 'age' because it is a read-only property.
    
  3. 使用 Readonly 类型实现只读数组:

    type ReadonlyStringArray = ReadonlyArray<string>;
    const arr: ReadonlyStringArray = ['hello', 'world'];
    arr.push('hi'); // Error: Property 'push' does not exist on type 'readonly string[]'.
    
  4. 使用 as const 将字面量类型变为只读类型:

    const person = { name: 'Alice', age: 30 } as const;
    person.name = 'Bob'; // Error: Cannot assign to 'name' because it is a read-only property.
    

namespace

是一种将代码组织到命名空间中的方式,以避免与其他代码产生命名冲突。可以将一个或多个类、接口、函数等放在同一个 namespace 中。

namespace MyNamespace {
  export interface Person {
    name: string;
    age: number;
  }
  export class MyClass {
    // ...
  }
  export function myFunction() {
    // ...
  }
}
const person: MyNamespace.Person = { name: "John", age: 30 };
const myClass = new MyNamespace.MyClass();
MyNamespace.myFunction();

使用 namespace 可以方便地组织代码,但需要注意不要滥用,否则会增加代码复杂度。同时,ES6已经引入了模块化系统,建议在新项目中优先考虑使用模块化。

declare

declare 关键字用于声明某些类型或变量的存在,但不实际定义其具体实现或值。通常情况下,它用于描述外部 JavaScript 库或其他第三方代码,以便 TypeScript 可以正确地编译和检查这些代码。

  1. 声明全局变量

    declare const myGlobal: string;
    

    声明了一个名为 myGlobal 的全局变量,并指定其类型为字符串。由于它没有被初始化,TypeScript 编译器不会生成任何实际的代码。

  2. 声明模块

    declare module "myModule" {
      export function myFunction(): void;
    }
    

    声明了一个名为 myModule 的模块,并导出了一个名为 myFunction 的函数。在使用 myModule 模块时,可以通过导入语句引入该模块中的函数。

  3. 声明命名空间

    declare namespace MyNamespace {
      export interface Person {
        name: string;
        age: number;
      }
    }
    

    声明了一个名为 MyNamespace 的命名空间,并导出了一个名为 Person 的接口。在使用 MyNamespace 命名空间时,可以通过点语法访问其中的成员。