typescripttypescript-generics

TypeScript partial of a generic type


I'd like to define a type as a mix of explicit properties and a generic type, with the explicit properties taking precedence in case of matching keys. Below is my attempt but I'm getting an error on the line indicated - can anyone explain why or is this a tsc/compiler bug?

// Takes properties in A and B. For matching properties, the types in A are used.
type Mix<A, B> = {
  [K in keyof B | keyof A]: K extends keyof A
    ? A[K]
    : K extends keyof B
    ? B[K]
    : never
}

type Versionable = { version: number }

function test<T>(): void {
  const version1: Partial<Mix<Versionable, T>>['version'] = 1 // compiles - version type is correctly inferred as number | undefined
  const version2: Partial<Mix<Versionable, T>>['version'] = undefined // compiles
  const version3: Partial<Mix<Versionable, T>>['version'] = '1' // does not compile as expected

  const obj1: Partial<Mix<Versionable, T>> = { version: 1 } // DOES NOT COMPILE.... WHY??
  const obj2: Partial<Mix<Versionable, T>> = { version: undefined } // compiles
  const obj3: Partial<Mix<Versionable, T>> = { version: '1' } // does not compile as expected
  const obj4: Partial<Mix<Versionable, T>> = {} // compiles
  obj4.version = 1 // compiles
}

Solution

  • I think the behavior here is the same as in microsoft/TypeScript#13442; almost no concrete values are seen by the compiler as assignable to Partial<SomethingDependingOnAGenericTypeParam> except for ones with properties of type undefined (this is responsible for obj2's behavior) or an empty object (this is responsible for obj4's behavior).

    This restriction is usually a defensible one because often these assignments are unsafe:

    function unsafeCaught<T extends { a: string }>() {
      const nope: Partial<T> = { a: "" }; // CORRECT error  
    }
    interface Oops { a: "someStringLiteralType" };
    unsafeCaught<Oops>(); // {a: ""} is not a valid Partial<Oops>
    

    (this is responsible for obj3's behavior.) But the restriction also prevents certain known-safe ones, like yours, which has been specifically crafted:

    function safeCaught<T extends { a: string }>() {
      const stillNope: { [K in keyof T]?: K extends "a" ? string : T[K] }
        = { a: "" }; // incorrect error
    }
    

    (this is responsible for obj1's behavior.) In these cases the compiler simply isn't performing the analysis necessary for this to work. A similar issue, microsoft/TypeScript#31070 was actually considered to be a bug and fixed, but in that case the mapped property type was a constant like number whereas in yours it's a conditional type. And the compiler already does a rather poor job of verifying assignability to conditional types that depend on unresolved generic parameters. So for your case I'd chalk this up to a design limitation of TypeScript and use a type assertion to establish that you know better than the compiler in this case:

    const obj1 = { version: 1 } as Partial<Mix<Versionable, T>>; // okay now
    

    Oddly enough, this restriction is loosened when you write to a property instead of assigning an object to the variable, so you end up with the very same unsound behavior with no error:

    function unsafeUncaught<T extends { a: string }>(val: Partial<T>) {
      val.a = ""; // uh, wait
    }
    const oops: Oops = { a: "someStringLiteralType" };
    unsafeUncaught(oops);
    

    I'm not sure why but it's probably a design decision somewhere. And therefore the following also gives you no error, but only coincidentally, since it's not properly checking in the first place:

    function safeUncaught<T extends { a: string }>(
      val: { [K in keyof T]?: K extends "a" ? string : T[K] }
    ) {
      val.a = ""; // okay but coincidentally
    }
    

    This is probably why your version1, version2, and version3 work, as well as obj4.version = 1.


    Link to code