Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Unclear type compatibility of primitives with generic mapped types #47633

Closed
ab-pm opened this issue Jan 27, 2022 · 9 comments
Closed

Unclear type compatibility of primitives with generic mapped types #47633

ab-pm opened this issue Jan 27, 2022 · 9 comments
Labels
Question An issue which isn't directly actionable in code

Comments

@ab-pm
Copy link

ab-pm commented Jan 27, 2022

Bug Report

🔎 Search Terms

primitive type partial object mapped type

💻 Code

I was trying to define my own DeepPartial generic type mapper. Here are three attempts (and, for comparison, the builtin Partial):

export type DeepPartial1<T> = T extends object ? { [K in keyof T]?: DeepPartial1<T[K]> } : T;
export type DeepPartial2<T> = { [K in keyof T]?: DeepPartial2<T[K]> };
export type DeepPartial3<T> = { [K in keyof T]?: T[K] extends object ? DeepPartial3<T[K]> : T[K] };

function example0<T>(x: T): Partial<T> {
    return x;
}
function example1<T>(x: T): DeepPartial1<T> {
    return x;
}
function example2<T>(x: T): DeepPartial2<T> {
    return x;
}
function example3<T>(x: T): DeepPartial3<T> {
    return x;
}

⏯ Playground Link

Passing a primitive value to the example function should correctly infer the same primitive type to be returned. DeepPartial<number> should be equivalent to number, not to a mapped object type.
This is why I chose implementation 1 with the type condition directly on T - and calling example1 with string or string | number for the type parameter does indeed infer that very same type as the result type of the call, unlike example2<string | number>(…) returning DeepPartial2<string | number>.

🙁 Actual behavior

Using DeepPartial1, which I thought is the correct implementation, doesn't work. On the return statement of the generic example1 function, tsc complains:

Type 'T' is not assignable to type 'DeepPartial1<T>'.(2322)

(also this message is not very helpful - there's no indication which properties are missing or incompatible)

🙂 Expected behavior

Using DeepPartial1 in the generic function should typecheck just fine.

Questions

I don't understand three things:

  • why the compiler complains about T not being compatible with DeepPartial1<T> - it seems to be a proper subtype to me regardless whether T extends object or not. Is it a bug or am I missing something?
  • why the similar construction in DeepPartial3 does work, instead of causing the same problem on the first nesting level
  • why Partial<number> is considered compatible with number - it seems that mapped helper types like Partial should only be used on objects. Why and how does this work, where is this behaviour documented?
@fatcerberus
Copy link

It's a design limitation - conditional types predicated on a generic type parameter are "deferred" (i.e. not actually evaluated) while in a generic context. Essentially, TS doesn't know whether or not T is assignable to DeepPartial1<T> because it hasn't resolved the conditional type yet (because it doesn't know what T is) and thus doesn't know how the input and output types relate--and assumes the worst. In cases like this you should use a type assertion.

@ab-pm
Copy link
Author

ab-pm commented Jan 27, 2022

@fatcerberus Ah, interesting. However, that doesn't explain why DeepPartial3 works - why then does TypeScript know that T[K] can be assigned to T[K] extends object ? DeepPartial3<T[K]> : T[K]? Shouldn't that also be deferred?

@fatcerberus
Copy link

That I'm not sure of. You would think so though, right? Someone else more knowledgeable about the internal workings of the compiler will need to chime in on that one. 😅. I presume it has something to do with the mapped type being known by the compiler to be homomorphic ([K in keyof T]) but I might be off-base on that.

I do seem to recall there was a special rule where if the compiler can figure out that a type is assignable to both sides of a conditional type, assignment is allowed even while deferred, but I don't know what triggers it.

@fatcerberus
Copy link

fatcerberus commented Jan 27, 2022

Ah, I think that's it - DeepPartial1 is distributive, so the "assignable to both sides of the conditional" rule doesn't come into play. T might be a union, in which case it would have be evaluated separately for each member of the union, which prevents TS from reasoning about it in the abstract. DeepPartial3 doesn't have this problem; T[K] extends ... is not distributive.

Consistent with this analysis, making DeepPartial1 non-distributive does make the error go away: See in Playground here

@ab-pm
Copy link
Author

ab-pm commented Jan 27, 2022

Oh, thanks! I didn't know about this neat little trick. Unfortunately, I think I would want my DeepPartial to be distributive, DeepPartial<number | SomeInterface> should be equivalent to number | DeepPartial<SomeInterface>. (Although apparently it is anyway, given {[K in keyof number]?: number[T]} is equivalent to number for some reason - that was my third question).

Also I don't understand why the compiler assumes that T can be a union but T[K] can't - but I think that is because it's more of a guessing game than what one would expect in a sound type system?

@fatcerberus
Copy link

fatcerberus commented Jan 27, 2022

Also I don't understand why the compiler assumes that T can be a union but T[K] can't

It's not that T[K] can't be a union - it's that T[K] extends ... doesn't trigger the distributive behavior (because T[K] isn't a bare type parameter), so TS can just go "okay, T[K] will be assignable to both sides of the conditional type regardless of what it is so I'll allow it despite it being deferred." It won't take that shortcut for distributive conditional types because the logic is more complicated (it would have to guarantee assignability individually for any number of input types).

@fatcerberus
Copy link

fatcerberus commented Jan 27, 2022

For what it's worth, soundness is explicitly not a goal of the type system - it's designed to prevent common errors in idiomatic JS and so you'll find there are a lot of these kinds of guessing games w.r.t. what the rules are. It's been my observation that beyond the basic structural type system which is fairly well-defined for concrete types, things are largely guided by pragmatism (and design constraints) in the more abstract corners of the system.

@ab-pm
Copy link
Author

ab-pm commented Jan 27, 2022

Yeah, that's what I meant - I know that TypeScript doesn't aim for a sound type system. Unfortunately that imo makes it very unpredictable when attempting to define more advanced precise types for generic functions, a game of try-and-error :-( But I'll stop my rant here and continue trying to learn about all the undocumented intricacies of the compiler implementation… Thanks for your help with that!

@RyanCavanaugh RyanCavanaugh added the Question An issue which isn't directly actionable in code label Feb 1, 2022
@typescript-bot
Copy link
Collaborator

This issue has been marked as 'Question' and has seen no recent activity. It has been automatically closed for house-keeping purposes. If you're still waiting on a response, questions are usually better suited to stackoverflow or the TypeScript Discord community.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Question An issue which isn't directly actionable in code
Projects
None yet
Development

No branches or pull requests

4 participants