发布于 2026-01-06 2 阅读
0

如何在 TypeScript 中像专业人士一样定义 React props 第一部分 第二部分 第三部分 第四部分

如何在 TypeScript 中像专业人士一样为 React props 设置类型

第一部分

第二部分

第三部分

第四部分

本文由四个互不相关的部分组成。

所有使用 TypeScript 和 React 的人都知道如何给 props 添加类型定义,对吧?

第一部分

假设我们有三个有效状态A,、BC



enum Mode {
  easy = 'easy',
  medium = 'medium',
  hard = 'hard'
}

type A = {
  mode: Mode.easy;
  data: string;
  check: (a: A['data']) => string
}

type B = {
  mode: Mode.medium;
  data: number;
  check: (a: B['data']) => number
}

type C = {
  mode: Mode.hard;
  data: number[];
  check: (a: C['data']) => number
}


Enter fullscreen mode Exit fullscreen mode

现在,我们必须确保我们的组件只接受有效的 props:



type Props = A | B | C;

const Comp: FC<Props> = (props) => {
  if (props.mode === Mode.easy) {
    const x = props // A
  }

  if (props.mode === Mode.medium) {
    const x = props // B
  }

  if (props.mode === Mode.hard) {
    const x = props // C
  }

  return null
}


Enter fullscreen mode Exit fullscreen mode

很简单,对吧?
现在,试试props.check在条件语句之外调用它。



const Comp: FC<Props> = (props) => {
  props.check(props.data) // error
  return null
}


Enter fullscreen mode Exit fullscreen mode

但为什么会出错?

TL;DR;

在反向变体位置上存在多个相同类型变量的候选者,会导致推断出交叉类型。

就我们而言:



type Intersection = string & number & number[] // never


Enter fullscreen mode Exit fullscreen mode

这就是为什么check需要never类型。

差点忘了,请不要忘记解构,它与 TypeScript 类型推断不太兼容:



const Comp: FC<Props> = ({ check, data, mode }) => {
  if (mode === Mode.easy) {
    check(data) // error
  }
  return null
}


Enter fullscreen mode Exit fullscreen mode

如果要使用解构,请同时使用类型保护符。



const isEasy = <M extends Mode>(
  mode: M,
  check: Fn
): check is Extract<Props, { mode: Mode.easy }>['check'] =>
  mode === Mode.easy


Enter fullscreen mode Exit fullscreen mode

既然我们在代码库中添加了额外的功能,我们就应该进行测试,对吧?

我想直接告诉你方法,无需任何额外检查。

我并不是说这种方法比使用类型保护更安全或更好。事实上,它并非如此。如果您不想对应用程序的业务逻辑进行任何更改,可以使用这种方法。更改之后,没人会要求您编写单元测试 :) 想象一下,您只需要从 [此处应填写具体类型] 迁移js到 [此处应填写具体类型] 的情况ts

为了实现调用,check我们需要对其进行重载。
让我们把练习分成 5 个小任务:

1. 获取属性为函数的键名。



// Get keys where value is a function
type FnProps<T> = {
  [Prop in keyof T]: T[Prop] extends Fn ? Prop : never
}[keyof T]

// check
type Result0 = FnProps<Props>


Enter fullscreen mode Exit fullscreen mode

2. 获取所有函数的并集。



type Values<T> = T[keyof T]

type FnUnion<PropsUnion> = Values<Pick<PropsUnion, FnProps<PropsUnion>>>

// | ((a: A['data']) => string) 
// | ((a: B['data']) => number) 
// | ((a: C['data']) => number)
type Result1 = FnUnion<Props>


Enter fullscreen mode Exit fullscreen mode

3. 计算不太具体的过载



type ParametersUnion<PropsUnion> =
  FnUnion<PropsUnion> extends Fn
  ? (a: Parameters<FnUnion<PropsUnion>>[0]) =>
    ReturnType<FnUnion<PropsUnion>>
  : never

// (a: string | number | number[]) => string | number
type Result2 = ParametersUnion<Props>


Enter fullscreen mode Exit fullscreen mode

4. 为了将函数并集转换为重载,我们需要使用交集而不是并集。因此,让我们将函数并集与一个不太具体的重载合并。




// credits goes to https://stackoverflow.com/a/50375286
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
  k: infer I
) => void
  ? I
  : never;

type Overload<PropsUnion> =
  & UnionToIntersection<PropsUnion[FnProps<PropsUnion>]>
  & ParametersUnion<PropsUnion>

// & ((a: A['data']) => string) 
// & ((a: B['data']) => number) 
// & ((a: C['data']) => number) 
// & ((a: string | number | number[]) => string | number)
type Result3 = Overload<Props>


Enter fullscreen mode Exit fullscreen mode

5. 最后一步。我们需要将合​​并后的函数与重载函数合并。换句话说,我们将重写 checkproperty 函数。



type OverloadedProps<PropsUnion> =
  & PropsUnion
  & Record<FnProps<PropsUnion>, Overload<PropsUnion>>


// Props & Record<"check", Overload<Props>>
type Result4 = OverloadedProps<Props>


Enter fullscreen mode Exit fullscreen mode

完整示例:



import React, { FC } from 'react'

enum Mode {
  easy = 'easy',
  medium = 'medium',
  hard = 'hard'
}

type A = {
  mode: Mode.easy;
  data: string;
  check: (a: A['data']) => string
}

type B = {
  mode: Mode.medium;
  data: number;
  check: (a: B['data']) => number
}

type C = {
  mode: Mode.hard;
  data: number[];
  check: (a: C['data']) => number
}

type Fn = (...args: any[]) => any

// credits goes to https://stackoverflow.com/a/50375286
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
  k: infer I
) => void
  ? I
  : never;


type Props = A | B | C;

// Get keys where value is a function
type FnProps<T> = {
  [Prop in keyof T]: T[Prop] extends Fn ? Prop : never
}[keyof T]

// check
type Result0 = FnProps<Props>

type Values<T> = T[keyof T]

type FnUnion<PropsUnion> = Values<Pick<PropsUnion, FnProps<PropsUnion>>>

// | ((a: A['data']) => string) 
// | ((a: B['data']) => number) 
// | ((a: C['data']) => number)
type Result1 = FnUnion<Props>


type ParametersUnion<PropsUnion> =
  FnUnion<PropsUnion> extends Fn
  ? (a: Parameters<FnUnion<PropsUnion>>[0]) =>
    ReturnType<FnUnion<PropsUnion>>
  : never

// (a: string | number | number[]) => string | number
type Result2 = ParametersUnion<Props>


type Overload<PropsUnion> =
  & UnionToIntersection<PropsUnion[FnProps<PropsUnion>]>
  & ParametersUnion<PropsUnion>

// & ((a: A['data']) => string) 
// & ((a: B['data']) => number) 
// & ((a: C['data']) => number) 
// & ((a: string | number | number[]) => string | number)
type Result3 = Overload<Props>

type OverloadedProps<PropsUnion> =
  & PropsUnion
  & Record<FnProps<PropsUnion>, Overload<PropsUnion>>


// Props & Record<"check", Overload<Props>>
type Result4 = OverloadedProps<Props>

const Comp: FC<OverloadedProps<Props>> = (props) => {
  const { mode, data, check } = props;

  if (props.mode === Mode.easy) {
    props.data // string
  }

  const result = check(data) // string | number

  return null
}


Enter fullscreen mode Exit fullscreen mode

请注意,这种输入 props 的方式是错误的。请将其视为临时解决方案。

第二部分

让我们来看 Stack Overflow 上的另一个例子。

3

我有两个组件,它们的 props 很相似,但有一个关键区别。其中一个组件TabsWithState只接受一个 prop tabs,它是一个对象数组,数组的元素形状如下:

interface ItemWithState {
  name: string
  active: boolean;
}

interface WithStateProps {
  tabs: ItemWithState[];
};

另一个类似的……



我们有两个具有相似属性的组件,其中tabs一个属性是公共的:



interface ItemWithState {
  name: string;
  active: boolean;
}

interface ItemWithRouter {
  name: string;
  path: string;
}

type WithStateProps = {
  tabs: ItemWithState[];
};

type WithRouterProps = {
  withRouter: true;
  baseUrl?: string;
  tabs: ItemWithRouter[];
};

const TabsWithRouter: FC<WithRouterProps> = (props) => null
const TabsWithState: FC<WithStateProps> = (props) => null


Enter fullscreen mode Exit fullscreen mode

此外,我们还有高阶分量:



type TabsProps = WithStateProps | WithRouterProps;

const Tabs = (props: TabsProps) => {
  if (props.withRouter) { // error
    return <TabsWithRouter {...props} />; // error
  }
  return <TabsWithState {...props} />; // error
};



Enter fullscreen mode Exit fullscreen mode

我们最终出现了三个失误。

由于该属性是可选的, TypeScript 不允许您获取withRouter它。它只允许您获取公共属性tabs。这是预期行为。

有一个修复/变通方法。我们可以添加withRouter?:neverWithStateProps类型定义中。
现在它能正常工作并推断出类型{...props}。但它有一个小缺点:它允许我们向Tabs组件传递非法属性:



import React, { FC } from 'react'

interface ItemWithState {
  name: string;
  active: boolean;
}

interface ItemWithRouter {
  name: string;
  path: string;
}

type WithStateProps = {
  withRouter?: never;
  tabs: ItemWithState[];
};

type WithRouterProps = {
  withRouter: true;
  baseUrl?: string;
  tabs: ItemWithRouter[];
};

const TabsWithRouter: FC<WithRouterProps> = (props) => null
const TabsWithState: FC<WithStateProps> = (props) => null

type TabsProps = WithStateProps | WithRouterProps;

const Tabs = (props: TabsProps) => {
  if (props.withRouter) {
    return <TabsWithRouter {...props} />;
  }
  return <TabsWithState {...props} />;
};

const Test = () => {
  return (
    <div>
      <Tabs // With incorrect state props
        baseUrl="something"
        tabs={[{ name: "myname", active: true }]}
      />
    </div>
  );
};


Enter fullscreen mode Exit fullscreen mode

这种方法不好。我们试试用 typeguard:




interface ItemWithState {
  name: string;
  active: boolean;
}

interface ItemWithRouter {
  name: string;
  path: string;
}

type WithStateProps = {
  tabs: ItemWithState[];
};

type WithRouterProps = {
  withRouter: true;
  baseUrl?: string;
  tabs: ItemWithRouter[];
};

const TabsWithRouter: FC<WithRouterProps> = (props) => null
const TabsWithState: FC<WithStateProps> = (props) => null

type TabsProps = WithStateProps | WithRouterProps;

const hasProperty = <Obj, Prop extends string>(obj: Obj, prop: Prop)
  : obj is Obj & Record<Prop, unknown> =>
  Object.prototype.hasOwnProperty.call(obj, prop);


const Tabs = (props: TabsProps) => {
  if (hasProperty(props, 'withRouter')) {
    return <TabsWithRouter {...props} />;
  }
  return <TabsWithState {...props} />;
};

const Test = () => {
  return (
    <div>  
      <Tabs // With incorrect state props
        baseUrl="something"
        tabs={[{ name: "myname", active: true }]}
      />
    </div>
  );
};


Enter fullscreen mode Exit fullscreen mode

我认为这种方法要好得多,因为我们不需要使用任何额外的可选属性hacks。我们的WithStateProps类型不应该有任何额外的可选属性。但它仍然存在同样的缺点:允许存在非法状态。

我们似乎忘记了函数重载。由于 React 组件本质上也是简单的函数,所以它的运作方式也相同。
请记住,函数的交集会产生重载:




// type Overload = FC<WithStateProps> & FC<WithRouterProps>

const Tabs: FC<WithStateProps> & FC<WithRouterProps> = (props: TabsProps) => {
  if (hasProperty(props, 'withRouter')) {
    return <TabsWithRouter {...props} />;
  }
  return <TabsWithState {...props} />;
};

const Test = () => {
  return (
    <div>
      <Tabs // With correct state props
        tabs={[{ name: "myname", active: true }]}
      />
      <Tabs // With incorrect state props
        baseUrl="something"
        tabs={[{ name: "myname", active: true }]}
      />
      <Tabs // WIth correct router props
        withRouter
        tabs={[{ name: "myname", path: "somepath" }]}
      />
      <Tabs // WIth correct router props
        withRouter
        baseUrl="someurl"
        tabs={[{ name: "myname", path: "somepath" }]}
      />
      <Tabs // WIth incorrect router props
        withRouter
        tabs={[{ name: "myname", active: true }]}
      />
    </div>
  );
};


Enter fullscreen mode Exit fullscreen mode

问:如果并集中有 5 个元素怎么办?
答:我们可以使用条件分布类型:



import React, { FC } from 'react'

interface ItemWithState {
  name: string;
  active: boolean;
}

interface ItemWithRouter {
  name: string;
  path: string;
}

type WithStateProps = {
  tabs: ItemWithState[];
};

type WithRouterProps = {
  withRouter: true;
  baseUrl?: string;
  tabs: ItemWithRouter[];
};

const TabsWithRouter: FC<WithRouterProps> = (props) => null
const TabsWithState: FC<WithStateProps> = (props) => null

type TabsProps = WithStateProps | WithRouterProps;

const hasProperty = <Obj, Prop extends string>(obj: Obj, prop: Prop)
  : obj is Obj & Record<Prop, unknown> =>
  Object.prototype.hasOwnProperty.call(obj, prop);

// credits goes to https://stackoverflow.com/a/50375286
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
  k: infer I
) => void
  ? I
  : never;

type Distributive<T> = T extends any ? FC<T> : never

type Overload = UnionToIntersection<Distributive<TabsProps>>

const Tabs: Overload = (props: TabsProps) => {
  if (hasProperty(props, 'withRouter')) {
    return <TabsWithRouter {...props} />;
  }
  return <TabsWithState {...props} />;
};

const Test = () => {
  return (
    <div>
      <Tabs // With correct state props
        tabs={[{ name: "myname", active: true }]}
      />
      <Tabs // With incorrect state props
        baseUrl="something"
        tabs={[{ name: "myname", active: true }]}
      />
      <Tabs // WIth correct router props
        withRouter
        tabs={[{ name: "myname", path: "somepath" }]}
      />
      <Tabs // WIth correct router props
        withRouter
        baseUrl="someurl"
        tabs={[{ name: "myname", path: "somepath" }]}
      />
      <Tabs // WIth incorrect router props
        withRouter
        tabs={[{ name: "myname", active: true }]}
      />
    </div>
  );
};


Enter fullscreen mode Exit fullscreen mode

您也可以采用这种方法:




type Overloading =
  & ((props: WithStateProps) => JSX.Element)
  & ((props: WithRouterProps) => JSX.Element)



Enter fullscreen mode Exit fullscreen mode

这是风格问题。

希望你还没累。

第三部分

假设我们有Animal一个组件,它具有以下约束条件:

  • 如果dogName为空字符串或未设置,canBark则应为 false
  • 如果dogName字符串不为空,canBark则应为真。



type NonEmptyString<T extends string> = T extends '' ? never : T;

type WithName = {
    dogName: string,
    canBark: true,
}

type WithoutName = {
    dogName?: '',
    canBark: false
};

type Props = WithName | WithoutName;


Enter fullscreen mode Exit fullscreen mode

由于 React 组件只是一个普通的函数,我们可以对其进行重载,甚至可以使用一些通用参数:




type Overloadings =
    & ((arg: { canBark: false }) => JSX.Element)
    & ((arg: { dogName: '', canBark: false }) => JSX.Element)
    & (<S extends string>(arg: { dogName: NonEmptyString<S>, canBark: true }) => JSX.Element)

const Animal: Overloadings = (props: Props) => {
    return null as any
}


Enter fullscreen mode Exit fullscreen mode

我们来测试一下:



import React, { FC } from 'react'

type NonEmptyString<T extends string> = T extends '' ? never : T;

type WithName = {
    dogName: string,
    canBark: true,
}

type WithoutName = {
    dogName?: '',
    canBark: false
};

type Props = WithName | WithoutName;


type Overloadings =
    & ((arg: { canBark: false }) => JSX.Element)
    & ((arg: { dogName: '', canBark: false }) => JSX.Element)
    & (<S extends string>(arg: { dogName: NonEmptyString<S>, canBark: true }) => JSX.Element)

const Animal: Overloadings = (props: Props) => {
    return null as any
}

const Test = () => {
    return (
        <>
            <Animal dogName='' canBark={false} /> // ok
            <Animal dogName='a' canBark={true} /> // ok
            <Animal canBark={false} /> // ok

            <Animal dogName='a' canBark={false} /> // error
            <Animal dogName='' canBark={true} /> // error
            <Animal canBark={true} /> // error
        </>
    )
}


Enter fullscreen mode Exit fullscreen mode

第四部分

假设我们有一个组件,它期望foo属性bar是字符串,但属性foo不能是空字符串hello

为了实现这一点,我们应该使用显式的泛型 forfoobar属性。
这很简单:



import React from 'react'


type Props<F extends string = '', B extends string = ''> = {
    foo: F;
    bar: B;
}

type ConditionalProps<T> = T extends { foo: infer Foo; bar: string } ? Foo extends 'hello' ? never : T : never

const Example = <F extends string, B extends string>(props: ConditionalProps<Props<F, B>>) => {
    return null as any
}


const Test = () => {
    <>
        <Example foo='hello' bar='bye' /> // expected error
        <Example foo='not hello' bar='1' /> // ok
    </>

}


Enter fullscreen mode Exit fullscreen mode

感谢阅读。

文章来源:https://dev.to/captainyossarian/how-to-type-react-props-as-a-pro-2df2