microsoft / TypeScript

TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
https://www.typescriptlang.org
Apache License 2.0
101.14k stars 12.5k forks source link

Tailoring declarations for entry points #57659

Closed malthe closed 8 months ago

malthe commented 8 months ago

πŸ” Search Terms

"declaration merging", "tree-shaking", "dead-code elimination"

βœ… Viability Checklist

⭐ Suggestion

The TypeScript compiler emits one declaration file per input, working from the exported symbols and their transitive type trees.

The suggested feature is to add a compiler option declarationEntryPoints to emit declarations for specific entry points such as src/index.ts such that only those "top-level exports" are included (along with the transitive type trees), skipping over symbols that are exported only for internal use (defined in more detail in motivation below).

// tsconfig.json
{
    ...
    "compilerOptions": {
        "declarationEntryPoints": [
            "src/index.ts"
        ]
    }
}

This is similar to the "*.d.ts rollup" feature in https://github.com/microsoft/rushstack/tree/main/apps/api-extractor:

You webpacked your library into a nice .js bundle file -- so why ship your typings as a messy tree of lib/.d.ts files full of private definitions?

Although the "messy tree" part is perhaps a matter of taste (whether to roll up declarations into a single file or emit as multiple files, but still without the private definitions).

πŸ“ƒ Motivating Example

When authoring a library, one often wants to split up the code into separate files, often exporting symbols for internal use and others for external use – the public-facing API of the library and what you would want to document using for example TypeDoc.

Internally used symbols are defined as those that are not in the transitive type tree of the type declaration entry point, e.g. dist/index.d.ts.

Meanwhile, the TypeScript compiler generates declarations per input file for each exported symbol and its transitive type tree and doesn't know about such a type declaration entry point or the idea that this file somehow defines the Public API.

As a result, packages come bundled with type declarations that are not used in practice.

While we could manually ask the compiler to strip out these internals using the @internal annotation, this is a tedious and error-prone process. Instead, we can now tell the compiler to emit declarations tailored to a set of entry points (which generally will correspond to the package subpath exports, often just a single input file).

πŸ’» Use Cases

  1. Optimizing the declarations bundled with published packages that have a well-defined public API.
  2. Stripping internals using annotations is a tedious and error-prone solution, the proposal here automates the process.
  3. Promotes modular code.
RyanCavanaugh commented 8 months ago

I'm not quite sure what this is describing apart from the status quo. Consumers are already seeing a minimal subset of your program through declaration emit.

Imports which aren't needed in the emitted declaration file are removed, so an importer of your library won't see those other files, so it's basically already the case that consumers see a minimal transitive tree. If needed you could write a post-build script to delete unneeded files, but they're basically benign.

malthe commented 8 months ago

As an example, consider @kafka-ts/core which is a library written in TypeScript.

In "utils.ts" we have a parseBrokers function that's exported and used internally in "kafka.ts":

import { parseBrokers } from './utils';

Meanwhile, a type declaration is emitted for this function in "utils.d.ts":

export declare function parseBrokers(brokers: TBrokers): string[];

But the function is not exposed – you can't import this function from your own code. Its type declaration could be removed as part of "dead-code elimination" (well, dead type) from the perspective of the would-be "index.ts" entry point.

RyanCavanaugh commented 8 months ago

Right, but the generated kafka.d.ts doesn't expose the import of utils in the first place:

import { Kafka } from 'kafkajs';
import type { IKafkaProps } from './interface';
interface IGroupedKafkaClientProps {
    [key: string]: Kafka;
}
interface IKafkaClientsProps {
    client: Kafka;
    getClient: (clientId?: string) => Kafka;
    getAllClients: () => IGroupedKafkaClientProps;
}
export declare function createKafkaClients(clientOptions: IKafkaProps | IKafkaProps[]): Promise<IKafkaClientsProps>;
export {};

Are you saying you want e.g interface.d.ts to be culled down to IKafkaProps + whatever IKafkaProps depends on?

malthe commented 8 months ago

Yes that's it – I want the parseBrokers function to be culled from utils.d.ts (given the specific entry point "index.ts").

RyanCavanaugh commented 8 months ago

Since this can generally be done syntactically post-build, there are a few projects out there doing something like this.

List courtesy @jakebailey : https://www.npmjs.com/package/dts-bundle-generator https://www.npmjs.com/package/rollup-plugin-dts https://www.npmjs.com/package/tsup https://www.npmjs.com/package/@microsoft/api-extractor https://github.com/Rich-Harris/dts-buddy

malthe commented 8 months ago

To some extent, the fact that this list exists is perhaps indicative that it's a useful feature to perhaps include in tsc – especially if it might fit well with how the compiler is implemented which I don't know.

RyanCavanaugh commented 8 months ago

It's more the opposite. Us creating a seventh .d.ts bundler is us spending time doing something that other people are manifestly capable of, whereas other work (making TS faster, making TS have more / better language features) is work that only we can do.

There'd have to be some demonstration that none of these tools are capable of doing an acceptable job at the task, or that the functionality being in-box provides some substantial upside over the status quo apart from convenience.

malthe commented 8 months ago

The main upside is that this is something everyone who publishes packages written in TypeScript benefits from using, knowing that if you point the compiler to your src/index.ts file it will emit a declaration suitable for exporting that entry point.

But I'll concede that there's an impressive list of tools that already are capable of doing an acceptable job here (although such tools arguably complicate the tool chain, raise the bar for contributing and add boilerplate).

fatcerberus commented 8 months ago

although such tools arguably complicate the tool chain

Note that the TypeScript Design Goals specifically call this out:

  1. [Do not] Provide an end-to-end build pipeline. Instead, make the system extensible so that external tools can use the compiler for more complex build workflows.

It's a bit like the Unix philosophy that you have a bunch of tools that each do one thing well and the only guiding principle is they should be composable. Whether or not it would simplify your workflow to have a self-contained jack-of-all-trades tool isn't something that even comes into consideration.[^1]

[^1]: Though I will concede that the way the way the shell works tends to unify things nicely - composition is a lot harder when both the input and output of the tools involved consists of multiple interconnected files.