aspect-build / aspect-build/rules_ts

[FR]: `types_only()`

Open
#319 4 comments 0 reactions 0 assignees View on GitHub
enhancement need: discussion
Dominant language
Starlark
Stars
138
Forks
87
Avg merge
2d 8h
Merged PRs (30d)
3

Description

### What is the current behavior?

`ts_project()` generally provides a `JsInfo` with `.d.ts` declarations _and_ `.js` sources, which is typically what you want in most situations. However, sometimes you want to type check against a dependency _without_ including the implementation of that dependency. This can happen because you expect the implementation to be provided as an input or linked separately. In my particular situation, I would like to type check a tool against my own `npm_package()` built at HEAD but _not_ include it in the output tool's implementation, since it will be dynamically linked against the same `npm_package()` in the user's workspace. See https://github.com/dgp1130/rules_prerender/issues/48#issuecomment-1182656780 for more context.

You can technically do this today with [`emit_declaration_only`](https://docs.aspect.build/rules/aspect_rules_ts/docs/rules/#emit_declaration_only) however this has two problems when applied to this use case:
1. It only applies to `ts_project()`, not `npm_package()`.
2. It must be applied to the `ts_project()` dependency, not the _usage_ of that dependency. When package `foo` is built, it should contain its full implementation. However a particular dependent of it may want to limit itself to just consuming the types of `foo`, rather than the full `*.js`.

You can also do this with `import type` which makes TypeScript prevent value references to that import. However there is nothing stopping misguided developers from _adding_ a value import in the future. _Also_ `import type` is not known to the Bazel layer and does not prevent emitting the JavaScript. This will still pull JavaScript from the type-only dependency, even if the import is elided at runtime. This can complicate dependency resolution, particularly in cases of multiple workspaces. You'd need to pass through a bundler to fully tree shake the unused JS implementation, and that shouldn't be needed for something like this.

### Describe the feature

I propose a `types_only()` rule which effectively strips the JS implementation from the `JsInfo` provider. You would use it like so:

```starlark
load("@aspect_rules_ts//ts:defs.bzl", "ts_project", "types_only")

ts_project(
name = "lib",
srcs = ["lib.d.ts"],
declaration = True,
# ...
)

# Use normally with JS sources included.
ts_project(
name = "app",
deps = [":lib"],
)

types_only(
name = "lib_types",
dep = ":lib",
)

# Depend only on the types, `lib.js` is not in the transitive output.
# `tsc` will error on value usages of the dependency.
ts_project(
name = "app_with_lib_types",
deps = [":lib_types"],
)
```

It implementation is pretty straightforward:

```starlark
load("@aspect_rules_js//js:providers.bzl", "JsInfo", "js_info")

def _types_only_impl(ctx):
info = ctx.attr.dep[JsInfo]
return js_info(
declarations = info.declarations,
npm_linked_package_files = depset(),
npm_linked_packages = depset(),
npm_package_store_deps = depset(),
sources = depset(),
transitive_declarations = info.transitive_declarations,
transitive_npm_linked_package_files = depset(),
transitive_npm_linked_packages = depset(),
transitive_sources = depset(),
)

types_only = rule(
implementation = _types_only_impl,
attrs = {
"dep": attr.label(
mandatory = True,
providers = [JsInfo],
doc = "`ts_project()` to remove JS implementation from."
),
},
doc = "Provides only the types of the given dependency, no implementation.",
)
```

Since `npm_link_package()` returns a `JsInfo` as well, this same implementation works there. However it has the caveat that NPM packages are implemented with a `TreeArtifact` in the `declarations` / `transitive_declarations` property. That `TreeArtifact` still maintains `*.js` sources, even if all the other properties of the provider are dropped. That means that if you use `types_only()` on an `npm_link_package()`, `tsc` will actually pass though the output won't include the `*.js` files. Ideally the `*.js` files would be removed from the NPM package and the `package.json` would be limited to just `.d.ts`, though I don't see an easily feasible way of doing that. Not sure what the best approach is there beyond accepting the foot gun that `tsc` will allow value references for dependents of `types_only()` of NPM packages.

### Fund our work

- [ ] Sponsor our open source work by donating a [feature bounty](https://opencollective.com/aspect-build/)

Contributor guide

Open the contributing guide

Research direction

Start by tracing the JsInfo providers returned by ts_project() and npm_link_package(), focusing on how declarations, sources, and transitive fields are consumed. Compare the proposed types_only() implementation with the existing rule entry points, then verify that type-only dependencies omit JavaScript from transitive output while preserving declarations.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
build-system
Issue type
Feature
Difficulty
4/5
Estimated time
3-5 days
Activity status
Stale
Clarity
Mostly clear
Newbie friendliness
45/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.