警告,前面的答案很长。摘要:
这里的主要问题是编译器根本不够聪明,无法验证泛型类型的某些等价性。
// If you use CompilerKnowsTheseAreTheSame<T, U> and it compiles,
// then T and U are known to be mutually assignable by the compiler
// If you use CompilerKnowsTheseAreTheSame<T, U> and it gives an error,
// then T and U are NOT KNOWN to be mutually assignable by the compiler,
// even though they might be known to be so by a clever human being
type CompilerKnowsTheseAreTheSame<T extends U, U extends V, V=T> = T;
// The compiler knows that Picking all keys of T gives you T
type PickEverything<T extends object> =
CompilerKnowsTheseAreTheSame<T, Pick<T, keyof T>>; // okay
// The compiler *doesn't* know that Omitting no keys of T gives you T
type OmitNothing<T extends object> =
CompilerKnowsTheseAreTheSame<T, Omit<T, never>>; // nope!
// And the compiler *definitely* doesn't know that you can
// join the results of Pick and Omit on the same keys to get T
type PickAndOmit<T extends object, K extends keyof T> =
CompilerKnowsTheseAreTheSame<T, Pick<T, K> & Omit<T, K>>; // nope!
为什么还不够聪明?一般来说,对此有两大类答案:
在这种情况下,它可能是后者。有an issue in Github about it,但我不希望看到很多工作,除非很多人开始要求它。
现在,对于任何 concrete 类型,编译器通常能够遍历并评估所涉及的具体类型并验证等价性:
interface Concrete {
a: string,
b: number,
c: boolean
}
// okay now
type OmitNothingConcrete =
CompilerKnowsTheseAreTheSame<Concrete, Omit<Concrete, never>>;
// nope, still too generic
type PickAndOmitConcrete<K extends keyof Concrete> =
CompilerKnowsTheseAreTheSame<Concrete, Pick<Concrete, K> & Omit<Concrete, K>>;
// okay now
type PickAndOmitConcreteKeys =
CompilerKnowsTheseAreTheSame<Concrete, Pick<Concrete, "a"|"b"> & Omit<Concrete, "a"|"b">>;
但在您的情况下,您正试图通过通用 T 实现它,这不会自动发生。
当您比编译器更了解所涉及的类型时,您可能需要明智地使用 type assertion,这是针对这种情况的语言的一部分:
function g<T extends A>(arg: Omit<T, 'x'>): T {
return { ...arg, x: "" } as T; // no error now
}
现在可以编译了,你就完成了,对吧?
好吧,我们不要太仓促。使用类型断言的陷阱之一是,当您确定自己正在做的事情是安全的时,您会告诉编译器不要担心验证某事。但是你知道吗?这取决于您是否希望看到一些边缘情况。这是您的示例代码最让我担心的地方。
假设我有一个 disciminated union 类型 U,这意味着 要么 持有 a 属性 或 b 属性,具体取决于x 属性的 string literal 值:
// discriminated union U
type U = { x: "a", a: number } | { x: "b", b: string };
declare const u: U;
// check discriminant
if (u.x === "a") {
console.log(u.a); // okay
} else {
console.log(u.b); // okay
}
没问题吧?但是等等,U 扩展了A,因为U 类型的任何值也应该是A 类型的值。这意味着我可以像这样调用g:
// notice that the following compiles with no error
const oops = g<U>({ a: 1 });
// oops is supposed to be a U, but it's not!
oops.x; // is "a" | "b" at compile time but "" at runtime!
值{a: 1} 可分配给Omit<U, 'x'>,因此编译器认为它产生了oops 类型的U 值。但事实并非如此,不是吗?你知道oops.x 在运行时既不是"a" 也不是"b",而是""。我们对编译器撒了谎,现在当我们开始使用oops 时会遇到麻烦。
现在也许这种极端情况不会发生在您身上,如果是这样,您不必太担心...毕竟,打字应该使维护代码更容易,而不是更难。
最后我想提一下,g() 函数作为类型化将永远无法推断出比A 更窄的T 类型。如果您调用g({a: 1}),T 将被推断为A。如果T 始终被推断为A,那么您甚至可能没有泛型函数。
可能出于同样的原因,编译器无法窥视Omit<T, 'x'> 足以理解它如何与Pick<T, 'x'> 连接以形成T,它无法窥视Omit<T, 'x'> 类型的值并弄清楚T 应该是什么。那么有什么办法呢?
编译器更容易推断您传递给它的实际值的类型,所以让我们尝试一下:
function g<T>(arg: T) {
return { ...arg, x: "" };
}
现在g() 将采用T 类型的值并返回T & {a: string} 类型的值。这总是会分配给A,所以你应该可以使用它:
const okay = g({a: 1, b: "two"}); // {a: number, b: string, x: string}
const works: A = okay; // fine
如果您想以某种方式阻止g() 的参数具有x 属性,那还没有发生:
const stillWorks = g({x: 1});
但我们可以通过对T 的约束来做到这一点:
function g<T extends { [K in keyof T]: K extends 'x' ? never : T[K] }>(arg: T) {
return { ...arg, x: "" };
}
const breaksNow = g({x: 1}); // error, string is not assignable to never.
这是相当类型安全的,不需要类型断言,并且更适合类型推断。所以这可能就是我要离开的地方。
好的,希望这篇中篇小说对你有所帮助。祝你好运!