【问题标题】:Omit type round-trip does not work with generics省略类型往返不适用于泛型
【发布时间】:2019-03-22 23:49:54
【问题描述】:

鉴于广泛共享的类型 Omit,其定义如下:

type Omit<ObjectType, KeysType extends keyof ObjectType> =
    Pick<ObjectType, Exclude<keyof ObjectType, KeysType>>;

用于减去类型(与相交相反),或者换句话说,从类型中删除某些属性。

我正在尝试使用这种类型来编写一个函数,该函数将获取一个 T 类型的对象,其中一个属性缺失,然后设置该缺失的属性并返回一个 T 类型的值。

以下使用特定类型的示例一切正常:

type A = { x: string }
function f(arg: Omit<A, 'x'>): A {
    return { ...arg, x: "" }
}

但是完全相同的函数,通用,不能编译:

function g<T extends A>(arg: Omit<T, 'x'>): T {
    return { ...arg, x: "" }
}

g的定义错误为:

键入'Pick> & { x: string; }' 不能分配给类型 'T'。

但我确定这个错误信息是错误的。键入Pick&lt;T, Exclude&lt;keyof T, "x"&gt;&gt; &amp; { x: string; } 可分配给T

我哪里出错了?


关于更多上下文,我正在编写一个 React 高阶组件,它将接受一个组件并自动提供一些已知的 props,返回一个删除了这些已知 props 的新组件。

【问题讨论】:

    标签: typescript generics


    【解决方案1】:

    警告,前面的答案很长。摘要:

    • 潜在问题是 known,但可能没有太大的吸引力

    • 简单的解决方法是使用类型断言return { ...arg, x: "" } as T;

    • 简单的修复并不完全安全,并且在某些极端情况下会产生不良结果

    • 无论如何,g() 不能正确推断出T

    • 在底部重构 g() 函数可能更适合你

    • 我需要停止写这么多


    这里的主要问题是编译器根本不够聪明,无法验证泛型类型的某些等价性。

    // 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!
    

    为什么还不够聪明?一般来说,对此有两大类答案:

    • 所讨论的类型分析依赖于一些人类的聪明才智,这在编译器代码中很难或不可能捕获。在奇点发生并且 TypeScript 编译器变得完全智能之前,您可以推断出编译器无法做到的一些事情。

    • 编译器执行所讨论的类型分析相对简单。但是这样做需要一些时间,并且可能会对性能产生一些负面影响。它是否改善了开发人员的体验,足以值得付出代价?不幸的是,答案通常是否定的。

    在这种情况下,它可能是后者。有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&lt;U, 'x'&gt;,因此编译器认为它产生了oops 类型的U 值。但事实并非如此,不是吗?你知道oops.x 在运行时既不是"a" 也不是"b",而是""。我们对编译器撒了谎,现在当我们开始使用oops 时会遇到麻烦。

    现在也许这种极端情况不会发生在您身上,如果是这样,您不必太担心...毕竟,打字应该使维护代码更容易,而不是更难。


    最后我想提一下,g() 函数作为类型化将永远无法推断出比A 更窄的T 类型。如果您调用g({a: 1})T 将被推断为A。如果T 始终被推断为A,那么您甚至可能没有泛型函数。

    可能出于同样的原因,编译器无法窥视Omit&lt;T, 'x'&gt; 足以理解它如何与Pick&lt;T, 'x'&gt; 连接以形成T,它无法窥视Omit&lt;T, 'x'&gt; 类型的值并弄清楚T 应该是什么。那么有什么办法呢?

    编译器更容易推断您传递给它的实际值的类型,所以让我们尝试一下:

    function g<T>(arg: T) {
      return { ...arg, x: "" };
    }
    

    现在g() 将采用T 类型的值并返回T &amp; {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.
    

    这是相当类型安全的,不需要类型断言,并且更适合类型推断。所以这可能就是我要离开的地方。


    好的,希望这篇中篇小说对你有所帮助。祝你好运!

    【讨论】:

    • 非常感谢@jcalz 的回复,它让我对幕后发生的事情有了更好的了解!我希望当 Negated Types 发布时(github.com/Microsoft/TypeScript/pull/29317),这种情况将变得既可行又非常简单。
    猜你喜欢
    • 2020-09-12
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2012-08-27
    • 2021-02-05
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多