拿下这套 Ts 类型体操

197 阅读15分钟

拿下这套 Ts 类型体操

什么是 Ts

TypeScript 是一种由微软开发的自由和开源的编程语言。它是 JavaScript 的一个超集,而且本质上向这个语言添加了可选的静态类型和基于类的面向对象编程。

简单来讲,TypeScript(TS)就是为 JS 提供了一个类型限定,它的主要目的是让 js 代码更加健壮和可维护,在学习 TS 时,最重要的一环就是理解并熟练运用类型系统。而类型体操则是通过各种 TS 类型操作技巧,来实现复杂的类型转换和推导,从而充分利用 TS 的类型系统。

TypeScript 的类型系统是一个静态类型系统,它通过在编译阶段对代码中的类型进行检查,来提供类型安全,帮助开发者避免一些潜在的错误。

大家取名的体操这个词还是非常形象的,说明 Ts 是可以玩出花来的

TypeScript 基础概念

基本类型

number、string、boolean、null、undefined、symbol、bigint、object

let name: string = "Conor";
let age: number = 22;
let isSingle: boolean = true;
let u: undefined = undefined;
let n: null = null;
let sym: symbol = Symbol();
let big: bigint = 12345678901234567890n;
let obj: object = { name: "Conor" };

未开启严格模式

name = null; // 允许
name = undefined; // 允许

开启严格模式

name = null; // Error:Type 'null' is not assignable to type 'string'.
name = undefined; // Error:Type 'undefined' is not assignable to type 'string'.

let nullableName: string | null = "Jack";
nullableName = null; // 允许,因为类型包含了 null
// void 类型
function logMessage(message: string): void {
  console.log(message);
}
let v: void;
v = undefined; //  允许
v = null; //  Error:Type 'null' is not assignable to type 'void'.
  • 默认未开启 strictNullChecks 选项
  • (非严格模式) nullundefined 可以赋值给任何类型,此时可以理解为它们是所有类型的子类型。
  • 如果开启了 strictNullChecks 选项(在 tsconfig.json 中开启), 只有明确标注为 nullundefined 的类型(例如 string | null、number | undefined )才能接收它们作为值。
  • undefined 可以赋值给 void 类型

any 类型

any 类型表示任意类型。使用 any 类型的变量可以赋值为任何类型的值,且不会进行类型检查。这在需要兼容动态类型的代码时有用,但滥用 any 会导致类型安全性丧失。(如果通篇使用 any 类型,相当于没有用 Ts, 而是 Js)

  • 要尽可能限制 any 的使用
let something: any = "hello";
something = 42; // 没有类型错误

unknown 类型

unknown 类型是一个更安全的 any 类型。与 any 不同,在将 unknown 类型赋值给其他类型之前,必须先进行类型检查或类型断言

function processValue(value: unknown) {
  if (typeof value === "string") {
    console.log("String value:" value);
  } else if (typeof value === "object" && value !== null) {
    console.log("Object value:", value);
  } else {
    console.log("Unknown type");
    //console.log("Unknown type", value.toUpperCase());
  }
}

processValue("Hubwise!"); // 输出: String value: Hubwise!
processValue({ id: 1 }); // 输出: Object value: { id: 1 }
processValue(true); // 输出: Unknown type
  • value.toUpperCase() 会报错,TypeScript 不允许直接对 unknown 类型的值调用属性或方法,因为它无法确定该值是否支持该操作。

TypeScript 的类型系统

类型推导

可以根据代码的上下文自动推导出变量、函数或表达式的类型,而无需显式地为其注释类型。

let age = 22; // age is number
const s: (v: string) => void = (v) => {}; // v is string

类型断言

有时候我们会很了解某个值的详细信息,很确切的知道它是什么类型。这时我们可以通过类型断言这种方式可以告诉编译器,“相信我,我知道自己在干什么”。

类型断言好比其他语言里的类型转换,但是不进行特殊的数据检查和解构。它只是在编译阶段起作用,运行时没有影响,即类型断言并不会改变值的实际类型。

“尖括号” 语法

let someValue: any = "this is a string";
let strLength: number = (<string>someValue).length;

as 语法

let someValue: any = "this is a string";
let strLength: number = (someValue1 as string).length;

interface Point {
  x: number;
  y: number;
}
const point: Point = {
  x: 1,
  y: 2,
  z: 3,
} as Point; //不加 as Point 就会报错:'z' does not exist in type 'Point'.

类型断言比较霸道,在添加 as Point 之后,TypeScript 跳过了对象字面量的额外属性检查,因为它认为你比编译器更清楚这个值的实际结构。

换句话说,类型断言相当于“强制转换”,让编译器忽略多余属性。所以推荐优先使用类型声明而非类型断言

基本规则
  1. 兼容的类型之间可以使用类型断言: TypeScript 允许在类型之间进行断言,只要它们之间具有某种兼容性。(基本类型和联合类型是兼容的)

    let a: any = "hello";
    let b: string = a; // 兼容的类型之间直接赋值
    let c: number = b as number; // 编译时不会报错,但运行时会失败
    
    let z: string | number = "hello";
    let w: string = z as string;
    console.log(w); // "hello"
    
  2. 任何类型都可以断言为 unknown 或从 unknown 断言为其他类型: unknown 是一种万能类型,几乎可以断言为任何类型,但使用之前需要谨慎。

  3. 允许断言为更具体的子类型或更宽泛的父类型

    • 更具体的子类型
    interface Animal {
      name: string;
    }
    interface Dog extends Animal {
      bark: () => void;
    }
    
    let dog: Animal = { name: "Olive" };
    let d = dog as Dog; // 强制将 Animal 类型断言为 Dog 类型
    d.bark();
    
    • 更宽泛的父类型
    let num: number = 123;
    let anyValue: any = num as any; // 将 number 断言为 any
    console.log(anyValue.length);
    
  4. never 类型不能被断言为其他类型:never 表示不可能存在的值,编译器会报错。

兼容类型:如果类型之间的结构相同,TypeScript 认为它们是兼容的。比如基本类型(string, number 等)和它们的子类型,或者联合类型和其他兼容的类型之间,通常可以进行类型断言。

泛型

泛型(Generics)使得类型可以参数化,允许编写可重用且灵活的代码。泛型的语法是 <> 中写类型参数,通常用 T 表示,理解为函数参数即可

我们先实现一个简单的函数,用于查找数组的第一个元素

function firstElementString(list: string[]) {
  return list[0];
}
function firstElementNumber(list: number[]) {
  return list[0];
}

我们发现每次添加一个新的类型,我们都要重新实现一遍该函数, 当然我们也可以直接使用 any

  • 小思考: 下面的代码会有什么问题
function firstElement(list: any[]) {
  return list[0];
}

const str = firstElement(["a", "b", "c"]);
console.log(typeof str);
const upperCase: number = str.toUpperCase();
console.log(upperCase);

但由于 any 不进行任何类型推断和类型检查,无法保证返回值的类型和传入参数类型的一致性,这时候使用泛型就比较合理

function firstElement<T>(list: T[]): T {
  return list[0];
}
const s = firstElement<string>(["a", "b", "c"]); // s is string
const n = firstElement<number>([1, 2, 3]); // n is number

调用的时候也可以不指明返回类型,可以自动的根据实参推断出类型变量的类型

const n = firstElement([1, 2, 3]); // n is number

千万别认为泛型any 的区别不大,泛型有类型检查,可通过 extends 进行类型约束。再举个例子

function processAny(input: any): any {
  return input.length; // 不安全:input 可能没有 length 属性
}
processAny(123); // 运行时错误:数字没有 length 属性

function processGeneric<T extends { length: number }>(input: T): number {
  return input.length; // 安全:泛型约束确保 input 有 length 属性
}
processGeneric("hello"); // 正常:字符串有 length 属性
processGeneric([1, 2, 3]); // 正常:数组有 length 属性
processGeneric(123); // 编译错误:number 没有 length 属性

但是所有的<>都是泛型吗? 肯定不是的。那如何区分 < > 的用途呢? 我们可以根据上下文决定用法:

  1. 如果 <> 出现在类型定义中,并且紧跟某个标识符(如 React.FC ),通常是类型参数。
  2. 如果 <> 包围某个值(如 <number>value),可能是类型断言。
  3. 如果 <> 出现在 JSX 文件中,并包含 HTML 或 React 组件标签,则是 JSX 语法。

这里的 < T> 表示泛型

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

类型参数传递:是在为类型别名 React.FC 提供类型参数。

const Component: React.FC<Props> = () => null;

//React.FC的类型签名
type FC<P = {}> = FunctionComponent<P>;

接口(interface)和类型别名(type)

绝大部分情况下,type 和 interface 都能等价转换

// 普通对象
type TState = {
  name: string;
  age: number;
};
interface IState {
  name: string;
  age: number;
}

// 索引签名(index signature)
type TDict = { [key: string]: string };
interface IDict {
  [key: string]: string;
}

// function
type TFn = (x: number) => string; //是用来描述函数的签名(即参数类型和返回值类型)的,不是真的箭头函数
interface IFn {
  (x: number): string;
}

// function with props
type TFnWithProps = {
  //小思考:为什么这里就不用箭头函数了?x和prop都是什么?
  (x: number): number;
  prop: string;
};
interface IFnWithProps {
  (x: number): number;
  prop: string;
}

// constructor
type TConstructor = new (x: number) => { x: number };
interface IConstructor {
  new (x: number): { x: number };
}

// extends
type TStateWithProps = IState & { hegiht: number };
interface IStateWithProp extends TState {
  height: number;
}

// implements
class StateT implements TState {
  name = "";
  age = "";
  sex = "male"; //类可以实现接口之外的属性或方法,这些不会受到 implements 的限制。
}
class StateI implements IState {
  name = "";
  age = "";
}
//接口定义了一种契约,规定了类必须实现的属性或方法。
//使用 implements 强制类遵循接口定义,可以提升代码的类型安全性和可维护性

答案:

  • 小思考:
  1. (x: number): number 描述了这个类型的函数部分,它接受一个参数 x,并返回一个数字。也就是规定了函数的参数和返回值类型,这正是 函数签名 的定义。
  2. prop: string 表示这个函数对象上会有一个名为 prop 的额外属性(函数作为一等对象,可以有自己的属性的),是附加到函数对象上的额外数据,我们叫他 附加属性
  3. 在运行时,这种区分是实际行为的映射:签名是函数的调用方式;附加属性是函数对象携带的数据
  4. 箭头函数类型 (x: number) => number 仅描述函数本身的签名,但无法附加额外的属性。如果需要为一个函数添加额外的属性,必须使用对象类型的语法。

interface 支持声明合并

interface IState {
  sex: string;
}

//合并后的 IState 等价于:
interface IState {
  name: string;
  age: number;
  sex: string;
}

type 不支持声明合并,但是支持定义联合类型、交叉类型或基本类型

  1. 交叉类型(&):合并多个类型。 type ID = string | number
  2. 联合类型(|):允许多个类型中的任意一种。 type Admin = User & { role: string }
type Status = "success" | "error"; // 可行
interface Status = "success"; // 不合法
interface Status = "success" | "error"; // 不合法

综合提升

  • 充分利用泛型和类型运算避免冗余类型标记, 使用泛型提取公共的 util type,简化类型编写
interface ButtonProps {
  type: string;
  size: "large" | "middle" | "small";
}
interface ButtonPropsWithChildren {
  type: string;
  size: "large" | "middle" | "small";
  children: React.ReactNode;
}

interface ButtonPropsWithChildren1 extends ButtonProps {
  children: React.ReactNode;
}

//使用PropsWithChildren简化
interface ButtonPropsWithChildren2 = PropsWithChildren<ButtonProps>

type PropsWithChildren<P = {}> = P & { children?: React.ReactNode | undefined };

  • 尽可能对整个函数表达式进行类型标注
function add(a: number, b: number) {
  return a + b;
}
function sub(a: number, b: number) {
  return a - b;
}
function mult(a: number, b: number) {
  return a * b;
}
function div(a: number, b: number) {
  return a / b;
}

type Binary = (a: number, b: number) => number;
const add: Binary = (a, b) => a + b;
const sub: Binary = (a, b) => a - b;
const mult: Binary = (a, b) => a * b;
const div: Binary = (a, b) => a - b;
  • 使用 Mapped Type 来实现值和类型的同步

假设我们实现了一个组件,并且使用 shouldComponentUpdate 来进行性能优化

class App extends React.Component<{
  x: number;
  y: number;
}> {
  shouldComponentUpdate(props) {
    return props.x !== this.props.x || props.y !== this.props.y;
  }
}

突然有一天你的组件添加了个新的 z 到 props, 虽然你扩展了你的 props 类型,但是你忘记修改了 shouldComponentUpdate,导致组件该重新渲染的时候没重新渲染。但是你回想,写代码的时候 ts 也没报错呀,不是有类型检查吗?这是为什么呢?

type AppProps = {
  x: number;
  y: number;
  z: number;
  onClick: () => {}; // 不需要检查它
};
class App extends React.Component<AppProps> {
  shouldComponentUpdate(props) {
    return props.x !== this.props.x || props.y !== this.props.y;
  }
}

答案:由于 TypeScript 是 静态类型检查工具,主要负责编译时的类型检查,不负责运行时行为的检查,所以不会检查 shouldComponentUpdate 内部逻辑

那我们该怎么做实现同步呢?再加一句 props.z !== this.props.z ?

No,No,No, 每次都要进行修改,太不高效了,我们之前学过策略模式,我们可以使用 Mapped Type 建立检查,下面的[k in keyof AppProps]保证了每次添加新的属性,都需要在 REQUIRED_UPDATE 进行添加

type AppProps = {
  x: number;
  y: number;
  z: number;
  onClick: () => {}; // 不需要检查它
};

const REQUIRED_UPDATE: { [k in keyof AppProps]: boolean } = {
  x: true,
  y: true,
  z: true,
  onClick: false,
};

class App extends React.Component<AppProps> {
  shouldComponentUpdate(props) {
    for (const k in this.props) {
      if (this.props[k] !== props[k] && REQUIRED_UPDATE[k]) {
        return true;
      }
    }
    return false;
  }
}

工具类型

Readonly:将对象的所有属性变为只读属性。

interface Person {
  id: number;
  name: string;
  email: string;
}

const user: Readonly<Person> = {
  id: 1,
  name: "Dolphin",
  email: "dolphin@meituan.com",
};

// 尝试修改只读属性会导致编译错误
user.id = 2; // Error: Cannot assign to 'id' because it is a read-only property.
user.name = "Bob"; // Error: Cannot assign to 'name' because it is a read-only property.

Partial:将类型的所有属性变为可选。

interface User {
  id: number;
  name: string;
  age: number;
}

type PartialUser = Partial<User>;

const user: PartialUser = { name: "Dolphin" }; // 只有 name 也是合法的

Pick:从类型中挑选部分属性。

interface User {
  id: number;
  name: string;
  age: number;
}

type UserIdAndName = Pick<User, "id" | "name">;
const user: UserIdAndName = { id: 1, name: "Dolphin" };

多个类型变量之间可以建立约束关系

function pick<T>(o: T, keys: keyof T) {}
pick({ a: 1, b: 2 }, "c"); // 报错,'c'不属于'a'|'b'

使用 index type | mapped type | keyof | pick 等进行类型传递

interface State {
  userId: string;
  pageTitle: string;
  recentFiles: string[]
  pageContents: string;
}
interface TopNavState {
  userId: string;
  pageTitle: string;
  recentFiles: string[]
}

//通过 lookup type简化
interface TopNavState = {
  userId: State['userId'];
  pageTitle: State['pageTitle']
  recentFiles: State['recentFiles']
}

//使用 mapped type 进一步简化
type TopNavState = {
  [k in 'userId' | 'pageTitle' | 'recentFiles'] : State[k]
}

//使用pick
type TopNavState = Pick<State, 'userId', 'pageTitle', 'rencentFiles'>

Required:将类型的所有的属性变为必需

其实接口中的属性,若是没用 ? 可选符号,默认就是必需的,required 的作用就是把 ? 去掉

interface Person {
  name?: string;
  email?: string;
}

type RequiredPerson = Required<Person>;

const person: RequiredPerson = {
  name: "dolphin",
  email: "dolphin@meituan.com",
};

Omit:Omit<T, K>从类型  T  中剔除  K  属性。

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

type OmitPerson = Omit<Person, "age">;

const person: OmitPerson = {
  name: "dolphin",
  email: "dolphin@meituan.com",
};

Record:Record<K, T> 构造一个类型,其属性名为 K 类型,属性值为 T 类型

type PersonInfo = Record<string, string>;

const person: PersonInfo = {
  name: "dolphin",
  email: "dolphin@meituan.com",
};

Exclude:Exclude<T, U> 从类型 T 中剔除可以赋值给 U 的类型

type PersonKeys = "name" | "email" | "age";
type ExcludePerson = Exclude<PersonKeys, "age">;

const person: ExcludePerson = "name"; // 只能是 "name" 或 "email"

Extract:Extract<T, U> 从类型 T 中提取可以赋值给 U 的类型

type PersonKeys = "name" | "email" | "age";
type ExtractPerson = Extract<PersonKeys, "name" | "email">;

const person: ExtractPerson = "name"; // 只能是 "name" 或 "email"

React 中 TS 的应用

基本结合

const Greeting: React.FC<GreetingProps> = (props: GreetingProps) => {
  const { name, age } = props;
  return (
    <div>
      Hello, {name}! You are {age} years old.
    </div>
  );
};
//如果属性较少可将(props: GreetingProps)替换为({ name, age })

event 的类型被定义为 React.MouseEvent< HTMLButtonElement>, TypeScript 可以自动推断出 event.currentTarget 是一个 HTMLButtonElement。

const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
  console.log("Button clicked!", event.currentTarget);
};

return <button onClick={handleClick}>Click Me</button>;
PropsWithChildren 结合 React
import React, { PropsWithChildren } from "react";

interface MyComponentProps {
  title: string;
}

const MyComponent: React.FC<PropsWithChildren<MyComponentProps>> = ({
  title,
  children,
}) => (
  <div>
    <h1>{title}</h1>
    {children}
  </div>
);

对应的父组件

import React from "react";
import MyComponent from "./MyComponent";

const ParentComponent = () => {
  return (
    <MyComponent title="Hello, World!">
      <p>This is the content passed as children!</p>
    </MyComponent>
  );
};

export default ParentComponent;

高阶组件(HOC)结合类型和复杂泛型

条件类型实现动态代理子组件

场景: 创建一个 HOC, 动态代理子组件, 自动传递子组件的某些属性。

type SubComponentProps<T extends boolean> = T extends true //小思考: T extends true 如何理解?
  ? { isActive: boolean }
  : { isDisabled: boolean };

function withSubComponentProps<T extends boolean>(
  Component: React.ComponentType<SubComponentProps<T>>,
  isActiveMode: T
) {
  return (props: SubComponentProps<T>) => <Component {...props} />;
}

const ActiveComponent: React.FC<SubComponentProps<true>> = ({ isActive }) => (
  <div>{isActive ? "Active" : "Inactive"}</div>
);

const DisabledComponent: React.FC<SubComponentProps<false>> = ({
  isDisabled,
}) => <div>{isDisabled ? "Disabled" : "Enabled"}</div>;

const EnhancedActiveComponent = withSubComponentProps(ActiveComponent, true);
const EnhancedDisabledComponent = withSubComponentProps(
  DisabledComponent,
  false
);

const App = () => (
  <>
    <EnhancedActiveComponent isActive={true} />
    <EnhancedDisabledComponent isDisabled={false} />
  </>
);
  • 使用 条件类型 T extends true 匹配不同的属性类型。
  • HOC 根据传入的布尔值 isActiveMode 确定要代理的子组件类型。

答案:

  • true 和 false 在 TypeScript 中并不是普通的值, 它们被 TypeScript 看作字面量类型(Literal Types)
type A = true; // A 的类型是字面量类型 `true`
type B = false; // B 的类型是字面量类型 `false`

动态增强组件属性

场景: 创建一个 HOC, 动态为传入的组件增加某些属性, 并保证类型安全。

import React from "react";

// 添加 `theme` 属性
function withTheme<P extends { theme?: string }>(
  Component: React.ComponentType<P>
) {
  //小思考1: 两个 return 都返回的是什么?
  return (props: Omit<P, "theme">) => {
    return <Component {...(props as P)} theme="dark" />;
  };
}

//可替换为
type WithExtraProps<P, ExtraProps> = Omit<P, keyof ExtraProps> & ExtraProps;

function withTheme<P>(
  Component: React.ComponentType<WithExtraProps<P, { theme: string }>>
) {
  return (props: P) => {
    return <Component {...props} theme="dark" />;
  };
}

type ButtonProps = {
  label: string;
  onClick: () => void;
  theme?: string;
};

const Button: React.FC<ButtonProps> = ({ label, theme, onClick }) => (
  <button
    style={{ backgroundColor: theme === "dark" ? "black" : "white" }}
    onClick={onClick}
  >
    {label}
  </button>
);

//小思考2: 此处Button没有显式传参, 当withTheme(Button)执行的时候, P是什么?
const ThemedButton = withTheme(Button);

const App = () => (
  <ThemedButton label="Click me" onClick={() => alert("Clicked!")} />
);
  • Omit: 删除 theme 属性, 防止 props 被误传入用户提供的值。
  • WithExtraProps: 用于动态扩展 P 的类型并合并增强的属性类型。

答案:

  • 小思考 1:

    1.外层 return: return (props: Omit<P, "theme">) => { ... }; 定义 HOC 的逻辑

    代表高阶函数 withTheme 返回了一个新的组件,它的返回值是一个匿名函数,该匿名函数的参数类型是 props: Omit<P, "theme">

    2.内层 return:定义新组件的行为,在运行时渲染目标组件,返回值是 React 组件的 JSX。

  • 小思考 2:Ts 的类型推导

    Button 是一个 React 组件, 类型为 ButtonProps, React 会自动为 Button 推断出类型: React.ComponentType< ButtonProps>。

    因此, 当我们调用 withTheme(Button) 时, TypeScript 会根据 Button 的类型推导出泛型参数 P。

推导过程
  1. Button 的类型是 React.ComponentType< ButtonProps>。
  2. HOC 要求组件类型为 React.ComponentType<WithExtraProps<P, { theme: string }>>。
  3. TypeScript 解析 WithExtraProps 并进行匹配
  4. Omit<P, "theme"> & { theme: string } = ButtonProps。
  5. 从中推导出 P = { label: string; onClick: () => void }。
  6. 最终, P 是去掉 theme 属性的部分, 而 theme 会在 HOC 中动态注入。

动态扩展回调函数的参数

场景: 创建一个 HOC, 可以动态为回调函数增加参数, 同时确保类型安全。

import React from "react";

type ModifyHandler<P, ExtraArgs extends any[]> = {
  //小思考:P是什么, 为什么可以P["onClick"]
  onClick: (
    ...args: [...Parameters<NonNullable<P["onClick"]>>, ...ExtraArgs]
  ) => void;
};

function withExtraArgs<P, ExtraArgs extends any[]>(
  Component: React.ComponentType<P>,
  extraArgs: ExtraArgs
) {
  return (props: Omit<P, "onClick"> & ModifyHandler<P, ExtraArgs>) => {
    const { onClick, ...restProps } = props;
    const enhancedClick = (...args: any[]) => {
      onClick?.(...args, ...extraArgs);
    };
    return <Component {...(restProps as P)} onClick={enhancedClick} />;
  };
}

type ButtonProps = {
  label: string;
  onClick?: (event: React.MouseEvent<HTMLButtonElement>, extra: string) => void;
};

const Button: React.FC<ButtonProps> = ({ label, onClick }) => (
  <button onClick={(e) => onClick?.(e, "Default Extra")}>{label}</button>
);

const EnhancedButton = withExtraArgs(Button, ["HOC Extra"]);

const App = () => (
  <EnhancedButton
    label="Click Me"
    onClick={(event, extra) => console.log("Event:", event, "Extra:", extra)}
  />
);

答案: P 是一个泛型参数, 它表示被传递的组件的 props 类型。P['onClick'] 表示从 P 对象中取出 onClick 属性的类型。

  • Parameters< T> 提取函数类型的参数类型,例如:(x: number, y: string) => void 的参数类型是 [number, string]
  • NonNullable< T> 保证 P["onClick"] 不为 null 或 undefined。如果 onClick 可能为空,这一步会排除空值。
  • ...args: [...Parameters<...>, ...ExtraArgs] 表示新的 onClick 参数是原来的 onClick 参数和额外参数的合并。

Ts 的应用范围

TypeScript 并不局限于前端开发,它同样可以在后端开发以及全栈开发中使用