Skip to content

Declaration emit can change type semantics when inlining internal types #46655

Closed

Description

@yortus

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

  1. andrewbranch commented on Nov 3, 2021

    @andrewbranch
    Member

    Which of those two emitted types exhibits the same behavior that you get when you’re consuming the .ts file directly? (I think it should be the ImportType type alias but just want to make sure I understand which one is the problem.)

    Edit: ah yeah, when you consume the .ts directly 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 😱

  2. troyvgw commented on Nov 4, 2021

    @troyvgw

    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?

  3. andrewbranch commented on Nov 4, 2021

    @andrewbranch
    Member

    I always have to look it up in the big comment in instantiateMappedType in 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?

  4. andrewbranch commented on Nov 4, 2021

    @andrewbranch
    Member

    (Indexed access types are also distributive)

    // (T | U)[K] -> T[K] | U[K] (reading)
    // (T | U)[K] -> T[K] & U[K] (writing)
    // (T & U)[K] -> T[K] & U[K]
    // / T[A | B] -> T[A] | T[B] (reading)
    // T[A | B] -> T[A] & T[B] (writing)
  5. yortus commented on Nov 5, 2021

    @yortus
    ContributorAuthor

    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 ExportedMapped above), 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, then T[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 of T[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]} or T extends U ? V : W, substituting T with 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.

  6. weswigham commented on Mar 2, 2022

    @weswigham
    Member

    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 emit T[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.

  7. yortus commented on Mar 4, 2022

    @yortus
    ContributorAuthor

    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?

    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 ExportedMapped above), 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.

  8. weswigham commented on Mar 4, 2022

    @weswigham
    Member

    Yeah, probably. I don't know if it can be fixed with the same rewriting anyway. It might be able to.

  9. yortus commented on Mar 6, 2022

    @yortus
    ContributorAuthor

    I opened #48140 for the conditional type case.

  10. locked as resolved and limited conversation to collaborators on Oct 21, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

BugA bug in TypeScriptFix AvailableA PR has been opened for this issue

Type

No type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions