Repository navigation
Declaration emit can change type semantics when inlining internal types #46655
Description
Bug Report
This is a cut-down repro for an issue I encountered while fixing a bug in rtti. What I saw was the same types behaving differently when consumed inside this package (ie referencing the types directly in src/) vs in downstream deps (ie referencing the emitted .d.ts types in dist/).
🔎 Search Terms
mapped type over union
mapped type over generic
🕗 Version & Regression Information
v4.4.4
v4.5 beta
v4.6 nightly (2 Nov 2021)
This is the behavior in every version I tried, and I reviewed the FAQ for entries about mapped types / homomorphic mapped types
⏯ Playground Link
Can't demonstrate in a single playground file.
💻 Code
/// file: test.ts
import {mappedUnionWithExportedType, mappedUnionWithPrivateType} from './api';
const a = mappedUnionWithExportedType({foo: 42}, {bar: 'bar'}); // type is {foo: number} | {bar: number}
'foo' in a ? a.foo.toFixed : a.bar.padStart; // OK
const b = mappedUnionWithPrivateType({foo: 42}, {bar: 'bar'}); // type is {}
'foo' in b ? b.foo.toFixed : b.bar.padStart;
// ^^^ ^^^ // ERRORS: 'foo' and 'bar' don't exist on {}
/// file: api.ts
import {useExportedType, usePrivateType} from './internal';
export const mappedUnionWithExportedType = <T extends unknown[]>(...args: T) => useExportedType(...args);
export const mappedUnionWithPrivateType = <T extends unknown[]>(...args: T) => usePrivateType(...args);
/// file: internal.ts
export declare function useExportedType<T extends unknown[]>(...args: T): ExportedMapped<T[any]>;
export declare function usePrivateType<T extends unknown[]>(...args: T): PrivateMapped<T[any]>;
export type ExportedMapped<Obj> = {[K in keyof Obj]: Obj[K]};
type PrivateMapped<Obj> = {[K in keyof Obj]: Obj[K]};🙁 Actual behavior
As shown in test.ts above, declaration emit type semantics change depending on whether an internal type is marked with export or not. In the above example, the declarations emitted for api.d.ts are:
export declare const mappedUnionWithExportedType: <T extends unknown[]>(...args: T) => import("./internal").ExportedMapped<T[any]>;
export declare const mappedUnionWithPrivateType: <T extends unknown[]>(...args: T) => { [K in keyof T[any]]: T[any][K]; };🙂 Expected behavior
Mapping over a union type seems silly to begin with (indeed that was the rtti bug I was fixing), but regardless of that, I would expect declaration emit semantics to be preserved whether or not tsc preserves an internal type in the emit, or expands it directly into a type expression.
Activity
andrewbranch commentedon Nov 3, 2021
Which of those two emitted types exhibits the same behavior that you get when you’re consuming the
.tsfile directly? (I think it should be theImportTypetype alias but just want to make sure I understand which one is the problem.)Edit: ah yeah, when you consume the
.tsdirectly or the.d.ts, you have a homomorphic mapped type which is distributive over its constraint, but when it gets inlined to{ [K in keyof T[any]]: T[any][K]; }it’s no longer homomorphic and no longer distributive 😱troyvgw commentedon Nov 4, 2021
Andrew Branch (@andrewbranch) I didn't realise before this that homomorphic mapped types are distributive. I thought only conditional types could have that behaviour. Is it documented somewhere?
andrewbranch commentedon Nov 4, 2021
I always have to look it up in the big comment in
instantiateMappedTypein checker.ts 😅I assume when mapped types were introduced maybe we wanted the freedom to change this, but it feels like it ought to be documented by now. Daniel Rosenwasser (@DanielRosenwasser) Orta Therox (@orta) thoughts?
andrewbranch commentedon Nov 4, 2021
(Indexed access types are also distributive)
yortus commentedon Nov 5, 2021
Andrew Branch (@andrewbranch) now you got me thinking about distributivity more generally, I just tried a similar repro but with conditional types, and the declaration emit has a similar 🐞 in that case. Specifically, you can have a conditional type expression that is distributive when the types are preserved in the emit (like
ExportedMappedabove), but is no longer distributive when the conditional type is inlined in the emit, leading to a change in type semantics of original code vs declaration emit.I guess the root issue is around referential transparency in the type system. Eg indexed access types are referentially transparent in the sense that if you have
T = A | B, thenT[K]can be replaced by(A | B)[K], and this will always preserve the same type semantics. In this case, distributivity is just a property ofT[K]that doesn't change under substitution.But if I'm understanding correctly, mapped and conditional types are not referentially transparent. Eg with either
{[K in keyof T]: T[K]}orT extends U ? V : W, substitutingTwith its value may change the meaning of the type expression. In these cases, distributivity isn't a general property of these types, its more like a 'mode' that is activated sometimes to change the meaning of the type, and that can have unintended consequences eg for type inlining like in this issue.weswigham commentedon Mar 2, 2022
So I have a fix for this, but it's going to look a bit odd - any time we would lose mapped type homomorphic-ness during declaration emit, we instead emit a conditional type that adds a new type parameter which we use to reintroduce homomorphic-ness. So in the example above, instead of
{ [K in keyof T[any]]: T[any][K]; };we emitT[any] extends infer T_1 ? { [K in keyof T_1]: T[any][K]; } : never;. It looks odd, but it does what you want - it makes the mapped type act like a homomorphic one again.yortus commentedon Mar 4, 2022
Andrew Branch (@andrewbranch) Wesley Wigham (@weswigham) I really appreciate your work on this issue, thanks. Should I open a separate ticket for the analogous issue I mentioned above with conditional types?
weswigham commentedon Mar 4, 2022
Yeah, probably. I don't know if it can be fixed with the same rewriting anyway. It might be able to.
yortus commentedon Mar 6, 2022
I opened #48140 for the conditional type case.