网站搜索

类型推断 - 如何根据部分已知的判别器来缩小 TypeScript 判别联合类型?


我正在开发一个 React 钩子,用于抽象多个共享某些函数参数的功能状态,但对于特定功能的某些参数应根据启用的功能要求或禁止。我有一个钩子函数,它接受一个参数,该参数是一个基于布尔功能标志属性的区分联合类型的对象,并且它返回一个对象,该对象根据启用的功能有条件地包含某些属性:

type CustomHookArgs = (
  | ({ isFeatureOneEnabled: true } & FeatureOneArgs)
  | { isFeatureOneEnabled?: false }
) &
  (
    | ({ isFeatureTwoEnabled: true } & FeatureTwoArgs)
    | { isFeatureTwoEnabled?: false }
  ) & {
    someSharedArgsHere: string;
  };

const useCustomHook = (args: CustomHookArgs) => {
  const { isFeatureOneEnabled, isFeatureTwoEnabled } = args;
  // Do some stuff
  return {
    ...args,
    someSharedThings: "here",
    ...(isFeatureOneEnabled && { someFeatureOneThings: "here" }),
    ...(isFeatureTwoEnabled && { someFeatureTwoThings: "here" }),
  };
};

如果我调用 useCustomHook 并开始输入 CustomHookArgs 对象的属性,VS Code IntelliSense 将允许/自动完成泛型(尚未缩小的)联合类型的属性:

{ isFeatureOneEnabled?: boolean; isFeatureTwoEnabled?: boolean; someSharedArgsHere: string }

FeatureOneArgs 和 FeatureTwoArgs 中的属性在此范围内被视为不属于 CustomHookArgs 类型,如果我包含它们,应该会导致类型错误(实际上只是有时有效,但那是另外一个问题)。如果我省略这些功能布尔值或将它们传递为 false,useCustomHook 返回的对象会被正确推断为不包含 someFeatureOneThings 或 someFeatureTwoThings 属性(或者正如我现在发现的,在 TS playground 中它被推断为 string | undefined。出于某种原因,这与我在自己环境中看到的情况不同)。

如果我使用 featureOneEnabled: true 调用 useCustomHook,args 对象会正确缩小,TS 要求我传入 FeatureOneArgs 的必需属性,但仍然不要求传入 FeatureTwoArgs 的属性。很好,到目前为止一切正常。

// No errors, as expected
useCustomHook({
  someSharedArgsHere: "hello"
});
// Error, as expected (someRequiredFeatureOneArg is required)
useCustomHook({
  someSharedArgsHere: "hello"
  isFeatureOneEnabled: true,
});
// No errors, as expected
useCustomHook({
  someSharedArgsHere: "hello",
  isFeatureOneEnabled: true,
  someRequiredFeatureOneArg: "here",
});

如果我尝试获取 hook 返回的对象并引用其属性,我本来希望在将 isFeatureOneEnabled: true 传递给 hook 时能够访问 someFeatureOneThings。但是这里有我的问题:hook 的返回值没有根据传入 useCustomHook 的值,从 CustomHookArgs 的缩小类型正确推断其类型。someFeatureOneThings 在该对象的类型上仍然不存在(在我的环境中……在 TS playground 中,它仍然是 string | undefined)。我希望它存在,并且类型为 string。

const hookReturnObj = useCustomHook({
  someSharedArgsHere: "hello",
  isFeatureOneEnabled: true,
  someRequiredFeatureOneArg: "hey!",
});

// No problem here
console.log(hookReturnObj.someSharedThings);

// Error: Property 'someFeatureOneThings' does not exist on type...
console.log(hookReturnObj.someFeatureOneThings);

如果我在引用特定功能的属性周围添加一个条件,它在该块中会正确地缩小范围:

if (hookReturnObj.isFeatureOneEnabled) {
  // No more error here
  console.log(hookReturnObj.someFeatureOneThings);
}

(好的,这里在 TS playground 中还有另一个区别——它根本没有缩小类型,someFeatureOneThings 仍然是 string | undefined)。

但我已经告诉 TS 当我调用 useCustomHook 时 isFeatureOneEnabled 是 true。为什么我还需要再次缩小它?

我是否误解了这里判别联合的预期行为?我本以为,如果让 useCustomHook 的返回类型从其 return 语句中推断出来,那么在我传入 isFeatureOneEnabled: true 时,类型上应该会存在 someFeatureOneThings 属性,而无需任何条件检查。

作为一种解决方法,是否有可能通过某种自定义工具类型显式地缩小类型?类似这样(不是实际代码):

type ReturnObjUnion = ReturnType<typeof useCustomHook>;
type ReturnObjWithFeatureOneEnabled = ReturnType<typeof useCustomHook({ isFeatureOneEnabled: true })>;

console.log((hookReturnObj as ReturnObjWithFeatureOneEnabled).someFeatureOneThings);

不过,这也不比必须添加一个条件好多少。

我尝试过的一件事是添加一个显式的返回类型并使用 satisfies(没有成功):

type CustomHookReturnObj = {
  someSharedThings: string;
} & (
  | ({ isFeatureOneEnabled: true } & { someFeatureOneThings: string })
  | { isFeatureOneEnabled?: false }
) &
  (
    | ({ isFeatureTwoEnabled: true } & { someFeatureTwoThings: string })
    | { isFeatureTwoEnabled?: false }
  );

const useCustomHook = (args: CustomHookArgs) => {
  const { isFeatureOneEnabled, isFeatureTwoEnabled } = args;
  // Do some stuff
  return {
    ...args,
    someSharedThings: "here",
    (...(isFeatureOneEnabled && { someFeatureOneThings: "here" })),
    (...(isFeatureTwoEnabled && { someFeatureTwoThings: "here" })),
  } satisfies CustomHookReturnObj;
};

任何帮助都将不胜感激。这里有一个 TS playground 链接,我一直在尝试重现这个问题,尽管我在那里看到的结果与我的项目中不同(如上所述)。

答案#1

你的 useCustomHook() 函数是作为一个依赖于联合类型 CustomHookArgs 的 args 参数的单块代码实现的。但是编译器只能对这段代码进行 一次 分析。它不会将分析“分发”到 CustomHookArgs 的各个联合成员上。因此,它会失去对各种 关联 的跟踪,并且返回的联合类型比你希望的要宽,包括实际不可能出现的项。你得到的是一种不加区分的情况混合。甚至没有办法要求编译器以这种方式分析单块代码(参见 microsoft/TypeScript#25051 的被拒绝功能请求)。如果你希望编译器正确处理不同的可能性,你实际上需要为每种情况写一块代码,就像这样:

const useCustomHook = (args: CustomHookArgs) => {
  const { isFeatureOneEnabled, isFeatureTwoEnabled } = args;
  if (isFeatureOneEnabled && isFeatureTwoEnabled) {
    return {
      ...args,
      someSharedThings: "here",
      someFeatureOneThings: "here",
      someFeatureTwoThings: "here",
    }
  }
  if (isFeatureOneEnabled && !isFeatureTwoEnabled) {
    return {
      ...args,
      someSharedThings: "here",
      someFeatureOneThings: "here",
    }
  }

  if (!isFeatureOneEnabled && isFeatureTwoEnabled) {
    return {
      ...args,
      someSharedThings: "here",
      someFeatureTwoThings: "here"
    }
  }

  return {
    ...args,
    someSharedThings: "here",
  }
}

这相当多余,但至少输出中不包含任何不可能的联合成员。相反,如果你想写一个单一的return语句,你可能需要使用类型断言或类似的方法来说服编译器该值符合你想要的输出类型。


此外,你期望 useCustomHook() 的返回类型依赖于你如何调用它。但对于联合类型的输入,这不会自动发生。让一个函数根据输入类型输出不同类型的唯一方法是要么对它进行重载,要么将其泛型化。

而且,不管怎样,编译器也不太可能能够遵循你实现的逻辑。重载通常会允许不安全的实现而不会抱怨,而泛型通常会过早地被扩展到约束上,你会因为安全的实现而得到错误。无论哪种方式,你可能都需要自己承担一些类型安全的责任,因为编译器要么允许过多(所以你需要小心),要么允许过少(所以你需要使用类型断言,这也需要小心)。


由你决定想要如何进行。一种可能性是让函数通用,找出输入类型和输出类型之间的类型关系,然后断言你的实现是按这种方式工作的。也许可以像这样:

type CustomHookArgs<F1 extends boolean, F2 extends boolean> =
  { isFeatureOneEnabled?: F1, isFeatureTwoEnabled?: F2, someSharedArgsHere: string } &
  { true: { someRequiredFeatureOneArg: string }, false: {} }[`${F1}`] &
  { true: { someRequiredFeatureTwoArg: string }, false: {} }[`${F2}`];

type CustomHookReturn<F1 extends boolean, F2 extends boolean> =
  { someSharedThings: string } &
  {
    true: { isFeatureOneEnabled: true, someFeatureOneThings: string },
    false: { isFeatureOneEnabled?: false }
  }[`${F1}`] &
  {
    true: { isFeatureTwoEnabled: true, someFeatureTwoThings: string },
    false: { isFeatureTwoEnabled?: false }
  }[`${F2}`];


const useCustomHook = <F1 extends boolean = false, F2 extends boolean = false>(
  args: CustomHookArgs<F1, F2>) => {
  const { isFeatureOneEnabled, isFeatureTwoEnabled } = args;
  return {
    ...args,
    someSharedThings: "here",
    ...(isFeatureOneEnabled && { someFeatureOneThings: "here" }),
    ...(isFeatureTwoEnabled && { someFeatureTwoThings: "here" }),
  } as CustomHookReturn<boolean, boolean> as CustomHookReturn<F1, F2>;
};

本质上,这个函数在 isFeatureOneEnabled 和 isFeatureTwoEnabled 属性的 boolean 类型上是通用的,分别表示 F1 和 F2。如果省略这些属性(并且编译器无法从中推断出泛型),它们默认值为 false。输入类型为 CustomHookArgs<F1, F2>,输出类型为 CustomHookReturn<F1, F2>。输入和输出本质上都是通过使用 F1 和 F2 的字符串化模板字面量类型作为索引的索引访问类型来进行切换的。这是一种避免使用条件类型的小技巧。如果你愿意,可以将 {true: X, false: Y}[`${F}`] 替换为 F extends true ? X : Y。

无论如何,我们还是确认一下从调用端来看它能按预期工作:

const obj1 = useCustomHook({
  someSharedArgsHere: "hello"
}); // okay
// const obj1: CHR<false, false>
obj1.someFeatureOneThings; // error
// Property 'someFeatureOneThings' does not exist on type 'CustomHookReturn<false, false>'

const obj2 = useCustomHook({
  someSharedArgsHere: "hello",
  isFeatureOneEnabled: true
}); // error!
// Property 'someRequiredFeatureOneArg' is missing.

const obj3 = useCustomHook({
  someSharedArgsHere: "hello",
  isFeatureOneEnabled: true,
  someRequiredFeatureOneArg: "here"
}); // okay
// const obj3: CHR<true, false>
obj3.someFeatureOneThings; // string

看起来不错。不过,你可能想用不同的方法,但你将会遇到上述提到的相同的一般问题。

代码的 Playground 链接