Skip to content

Declaration emit should not inline type definitions #37151

Description

@mheiber

TypeScript Version: 3.8.3, 3.8.1, probably others

Search Terms:

declaration inlining, dts inlining, declaration inline, inline literal, declaration literal

Code

// parent.ts
import { num, obj } from "./child"
export const reExportNum = num;
export const reExportObj = obj;
// child.d.ts
export declare const num: number;
export declare const obj: { a: 1 };

tsc index.ts --declaration

Expected behavior:

Declaration emit for parent.ts should not inline types.

// parent.d.ts
import { num, obj } from "./child"
export declare const reExportNum: typeof num;
export declare const reExportObj: typeof obj;

Actual behavior:

Today, declaration emit for parent.ts inlines the types and eliminates the import of the child.d.ts type definition.

// parent.d.ts
export declare const reExportNum: number;
export declare const reExportObj: {
    a: 1;
};

This is a correctness issue, because consumers of parent.d.ts will not get the correct types if the types in child.d.ts change.

In practice, this is most likely to happen when parent and child are in separate packages, because they are published independently, i.e. an application uses parent-package which uses types from child-package. This is exacerbated by the current practice on npm of parent-package depending on an unpinned version, using package.json dependency syntax "child-package": "*".

This issue was co-authored with Rob Palmer (@robpalme)

Activity

  1. DanielRosenwasser commented on Mar 3, 2020

    @DanielRosenwasser
    Member

    What would you have TypeScript emit in the case of the following?

    export const exportNumNum = num + num;
    
    export const exportExtendedObj = {
      ...obj,
      someOtherProp: 200,
    };
  2. mheiber commented on Mar 4, 2020

    @mheiber
    ContributorAuthor

    Thanks for looking into this, Daniel Rosenwasser (@DanielRosenwasser) . As your examples illustrate, there is a long tail of correctness issues in this area.

    If examples like the one in the issue description are the common case, maybe it would be possible to solve them independently of the uncommon cases.

    We have some ideas for how to deal with the less common cases and can open a separate issue for them if you think it's a good idea. Perhaps related: #29043.

  3. RyanCavanaugh commented on Mar 4, 2020

    @RyanCavanaugh
    Member

    There's a fairly fundamental tension here about what declaration emit means: Should declaration files represent the types as they existed when you compiled your program, or should they represent the types that a consuming library would have seen had your original program been compiled "in the context of" the consumer's setup?

    This gets really mind-bending if you think about conditional types or overloads, e.g.

    declare var x: SomeType;
    export const c = func(x);

    Is the intent here that c should have the type that you saw when you invoked func? Or if a consuming library augments SomeType in a way that changes the resulting type of func(x), should c have some other new type? What if that causes some other use of c to break a contract?

    I think one of these behaviors is much easier to reason about than the other, as you can probably tell from my descriptions of them.

    Anyway the example in the OP is also in tension with people who want their end-result .d.ts file to be a single-ish artifact that doesn't expose their entire program's internal structure. This is a good goal anyway for performance - it'd be much better if we load 1 file per library instead of 12, and better if we handle some inline anonymous types instead of resolving typeof queries everywhere.

  4. mheiber commented on Mar 5, 2020

    @mheiber
    ContributorAuthor

    reply to #37151 (comment)

    Ryan Cavanaugh (@RyanCavanaugh) thanks for your response, I find your description of the two mental models really helpful: the "types are conceptually inlined" model vs. the "types always flow through" model.

    I'm not sure the inlining is easier to reason about, given that "types flow through" is how .ts files work and is also similar to how the runtime works:

    export { foo } from "./foo"; // re-exposes whatever is in ./foo

    It also seems like the inlining model doesn't work for nominal types: it would lead to spurious type incompatibility errors. That's probably why TS currently does not inline nominal types.

    So if the choices are:

    • two mental models, one of which leads to runtime errors
    • a single mental model in which types are correct

    I'd choose the latter. Please let me know if this is a misleading way of stating the choices!

  5. mheiber commented on Mar 16, 2020

    @mheiber
    ContributorAuthor

    I'm curious about this issue: is there a way to help progress this?

    I could open a separate issue for Ryan Cavanaugh (@RyanCavanaugh) 's more fundamental topic re the point of a declaration file.

  6. mheiber commented on Mar 31, 2020

    @mheiber
    ContributorAuthor

    Following up on this issue: it's causing us some pain, so it would be good to have some guidance.

  7. RyanCavanaugh commented on Mar 31, 2020

    @RyanCavanaugh
    Member

    Max Heiber (@mheiber) have you tried with @next lately? We recently had some PRs go through that might improve the emit to more closely match your expecations.

  8. weswigham commented on Mar 31, 2020

    @weswigham
    Member

    If you're thinking of #37444, that's still out for review.

  9. mheiber commented on Mar 31, 2020

    @mheiber
    ContributorAuthor

    Thanks Ryan and Wes! I'll check out Wes' PR.

  10. mheiber commented on Mar 31, 2020

    @mheiber
    ContributorAuthor

    Thanks for the recommendation. I tried #37444 and am not seeing a difference in the output for the example above.

  11. weswigham commented on Mar 31, 2020

    @weswigham
    Member

    Yeah, didn't think it would - you're looking for everything to be represented with typeof queries and indexed accesses where possible, essentially. Which is just plain not something we even track the information to do right now.

    Naturally, you can always just write the type annotations yourself if preserving that last bit of origin information is important (eg, because you expect augmentations somehow).

  12. mheiber commented on Apr 1, 2020

    @mheiber
    ContributorAuthor

    Wesley Wigham (@weswigham) thanks for explaining. After looking at your related PR, I think I can see how the information is not currently tracked.

    Regarding design (rather than implementation), the fundamental issue seems to me to be that declaration files are neither:

    • fully dynamic: types always "flow through" from transitive dependencies, just like values flow through at runtime
    • fully static: types are always fully inlined

    I gave some reasons above why I see advantages to the dynamic model (#37151 (comment)). One of the most compelling reasons, in my opinion, is that it's the only way I can see that will work well with nominal types.

    Am I understanding the design issue correctly?

  13. mheiber commented on Jun 24, 2020

    @mheiber
    ContributorAuthor

    Daniel Rosenwasser (@DanielRosenwasser) re:

    What would you have TypeScript emit in the case of the following?

    export const exportNumNum = num + num;
    
    export const exportExtendedObj = {
      ...obj,
      someOtherProp: 200,
    };

    A superpowered typeof that works for arbitrary expressions would solve this problem:

    export declare const exportNumNum: typeof num + num;

    Would also address:

    Would typeof with arbitrary expressions be worth considering in a separate issue? Titian Cernicova-Dragomir (@dragomirtitian) experimented with implementing this before.

  14. cyberixae commented on Aug 27, 2020

    @cyberixae

    I'm here because inlining everything caused my .d.ts file to be over 6.5MB long. The type checks started failing and I am wondering if the compiler simply ignores the end of the file because of some limitation.

  15. robpalme commented on Aug 27, 2020

    @robpalme

    Toni Ruottu (@cyberixae) One mitigation to reduce inlining is to use interface rather than type when defining object shapes. This causes tsc to reference the original type by name, which may further cause it to generate type-only import() expressions.

  16. aleksey-ilin commented on Dec 19, 2020

    @aleksey-ilin

    Max Heiber (@mheiber) Have you find a solve for this problem?

  17. mheiber commented on Dec 19, 2020

    @mheiber
    ContributorAuthor

    IlinAlekseyS (@aleksey-ilin) I'm not writing TS full-time anymore, Rob Palmer (@robpalme) is more up to date. But my understanding is that this is a fundamental issue with TS not picking a consistent model for type inlining.

  18. robpalme commented on Jan 7, 2021

    @robpalme

    IlinAlekseyS (@aleksey-ilin) the main solution I have found to solve huge declarations is to identify the root type that is inlined and then, assuming it is a statically known object type, create an interface from it and then refer to that interface at all usage sites.

    export interface WrappedProblemType extends ProblemType {}

    I have been experimenting with changing declaration emit so that shenanigans like this are not necessary. It kinda works and I'll share that soon.


    Separately, union and intersection types also get inlined. interface will not save you in this case - there is no reliable userland workaround for these. Thankfully there is work in progress to reduce the inlining of these specific types in #42149

  19. electrovir commented on Sep 15, 2024

    @electrovir

    The fact that the TypeScript compiler does this has caused many issues for me, including:

    1. JSDoc comments on the original code are not preserved in the inlined types. (example, every method is missing its JSDoc comment)
    2. Large union type aliases that get inlined (multiple times throughout the project) explode the declaration file size and make editor tooltips impossible to read. (example, scroll to the right)
    3. Sometimes type imports from dependencies are wiped out entirely, breaking everything. (example, fixed by writing my own declaration file here)
  20. robpalme commented on Sep 16, 2024

    @robpalme

    Thank you for the examples.

    There's more work on the way related to Isolated Declarations that may mitigate 1 and 2.

    3 just sounds like a bug. If you have a small repro, please file a standalone issue.

  21. samchungy commented on Nov 6, 2024

    @samchungy

    Just ran into this little issue too.

    Should declaration files represent the types as they existed when you compiled your program, or should they represent the types that a consuming library would have seen had your original program been compiled "in the context of" the consumer's setup?

    I've got a package I'm trying to use types from. For the sake of this repro it's "./bar";

    foo.ts

    import { Bar } from "./bar";
    
    export const foo = <T extends Bar = Bar>(bar: T) => "";

    bar.ts

    export const bar = ["a", "b", "c"] as const;
    
    export type Bar = (typeof bar)[number];

    emits:

    import { Bar } from "./bar";
    export declare const foo: <T extends Bar = "a" | "b" | "c">(param: T) => string;

    Which is kinda both worlds? We've got the extends Bar but then the inlined definition? Which is particularly tricky when the type of Bar gets updated in a separate package update.

    If I change Bar to the type 'a' | 'b' | 'c' it won't inline the type.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

Needs InvestigationThis issue needs a team member to investigate its status.

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions