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
}
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
.