-
Notifications
You must be signed in to change notification settings - Fork 12.5k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
--isolatedDeclarations
for standalone DTS emit
#47947
Comments
Even if you could force explicit type annotations on everything, I don’t think that would be enough to enable parallel compilation, since the structural typing means the compiler has to know what the imported types contain in order to check them. |
Bruce: the assumption would be that you generate `.d.ts` files in one step,
which is purely syntactical, i.e. no type checking happens here. And then
you type check all sources (`.ts` and `.d.ts`) in a second step. In the
second step, you have all `.d.ts` files that you generated in the first
step available - so you can perform all checks that TS does as it would
today.
|
Thanks for raising this issue. This is a feature/area I had been considering prototyping for a while. It sounds like we're thinking of the same thing but I'll elaborate here so you can verify. Feature DescriptionThe key goal is to allow a declaration This kind of standalone declaration generation isn't possible for the full set of TypeScript source files today. An example problematic case is when an exported type is computed based on types originated in dependencies. Declaration files can't express transitive concepts such as import { a, b } from "./dependency";
export const sum = a + b; // declaration emit will resolve this to a singular string or number Potentially new declaration syntax could be added to mitigate this. But a general solution is to constrain the set of TypeScript that can be authored. Think of it as introducing stronger linting rules. When this need arose previously to permit standalone per-file TS->JS compilation, the Pros & ConsAs you say, this feature will permit parallelisation of declaration emit. It will also reduce the blast radius of declaration regeneration needed when editing a single file. Another less obvious benefit is that it will improve type-checking performance for consumers of otherwise bloated declaration files - by eliminating the (increasingly rare) edge cases where excessive inlining super-sizes the declaration files. And when declaration emit is decoupled from type-checking, it opens the door for high-performance declaration emit tools in other languages, e.g. Rust/Go/Zig. A downside of forcing isolation of declaration files is that it may increase the count of file accesses for anyone consuming that declaration file set during type-checking. Per-file overheads are noticeable particularly on Windows and particularly when malware scanners intercept the file access. This can be mitigated by declaration bundling. |
@robpalme yes, what you're writing here matches my understanding. And indeed, One thing I don't follow on: why do you think this will increase the number of file accesses needed during type checking? Shouldn't the compiler just read the exact same set of |
.d.ts
emit through explicit type annotations (--noTypeInferenceOnExports?).d.ts
emit through explicit type annotations (--isolatedDeclarations, --noTypeInferenceOnExports?)
My fault for not being clear. This proposal is really two things:
It is possible to deliver (1) without (2). But delivering (2) requires (1). (2) may entail more file accesses during checking, as it guarantees no inlining of types. Last I checked, the checker is lazy and only loads imports that are truly needed to check something. Inlining, as opposed to referencing, will lead to more cases where that laziness pays off and means a dependency file does not need to be loaded. This is a minor/rare case that shouldn't really sway anything. |
I would frame it this way:
The potential perf gains here are indeed extremely large if we think about non-error-checking scenarios. We'll have to consider what the edge cases are where the syntactic rules might be sufficient to capture this invariant, but it's a very interesting proposal. |
This looks like type-first mode of Flow. They obtained a great perf boost. |
I started doing some experimentation with |
One more pro that wasn't explicitly mentioned yet is that it would allow full emits for monorepos using project references even when there is a type error. This is a significant benefit; I can't count the number of times I felt trolled by This was pointed out to me in this tweet: https://twitter.com/robpalmer2/status/1521973705404043268?t=WUpGGePVAL0ZEjlJGfVc_g&s=19 |
I realized today that this is somewhat dependent on #13626 because these are the only declarations that do not permit an annotation, but permit arbitrary expressions. I'd be surprised if there wasn't someone who needed an In theory we don't have to do #13626. we could say that this mode only works for |
@DanielRosenwasser It will create a restriction for default exports. But there are probably other limitations as to what you can write in this mode. For example there is no place to annotate a class that extends an expression either: class Base {}
function id<T> (cls: T){ return cls;}
// No place to annotate id(Base)
class Derived extends id(Base) {} |
I had a similar pain point where the 3rd party dependency didn't have its inner type exported causing TS cannot even compile under composite mode, and I proposed restricting the list of types generated in the It seems that they're highly related and can complement each other. Restricting what will be generated in .d.ts can probably improve the perf as well in the way that less things needs to be checked against a dependent module. |
FWIW this issue came up a bit in the context of "type-mutating decorators" #51347 |
The issue was part of #49074 (TypeScript 4.8 Iteration Plan) and #50457 (TypeScript 4.9 Iteration Plan) but is not part of #51362 (TypeScript 5.0 Iteration Plan). @DanielRosenwasser Do you still have plans to investigate this or did this drop from the roadmap for the foreseeable future? |
Sorry for the long time since the last update. I have been partially working on this, but other duties have made progress a bit slow. This will become my main project starting next week, and I expect to continue working on it for at least the next couple of months. So far I have a partial implementation of the flag in TypeScript which I have pushed here. I have also been working on an independent implementation of declaration emit. This is based on the TypeScript emitter - the code is literally extracted. The idea with this external implementation is to:
Some preliminary findings:
|
@dragomirtitian Do you mind sharing some docs about expando functions? Never heard about that. |
( Search this notes page for "expando" to get some details ) |
.d.ts
emit through explicit type annotations (--isolatedDeclarations, --noTypeInferenceOnExports?)--isolatedDeclarations
for standalone DTS emit
I'm probably the least qualified person here, but I think this is not the right way to approach this problem. If you ask folks what's the lest part of golang, it'll be most likely writing verbose types. Of course it allows a super fast type checking but in TS types are not as simple as in go. TS types can be very complex, and hand writing them is just a burden. We should require folks to write less verbose code, and opt in for more inference, then the TypeScript will be loved by all! |
Maybe I am missing something, but this is how declaration emit works today. When you write With isolated declaration emit, you can emit |
Maybe I'm misunderstanding the problem. What's the difference between having the types hand written, or generated by the compiler? If we could generate the same declaration that this proposal is asking for by the compiler, shouldn't it solve the problem?
Update: Time will tell but I think it will take lots of time, and efforts for all TS users to adapt this proposal. It will reduce DX, spiral controversies, and spread confusions by generating errors for existing types in the community. |
Fully annotating is an opt-in for faster build performance and easier integration with other build tools like bundlers (which could themselves also create declaration file bundles!), so it's on a team-by-team basis to decide whether or not it's a worthwhile tradeoff.
It wasn't clear to me, but let me take a different stab at this. It sounds like what you're asking is "why can't the compiler just separately infer these things and print them out?". For a codebase where flowchart BT
B -->|Depends On| A
C -->|Depends On| A
could one perform declaration emit with the compiler on I suppose technically one could! It's not entirely necessary for the compiler to perform a full type check to perform declaration emit if you perform it separately, though in the worst (maybe somewhat-uncommon) case, you could still end up doing a full type-check across the program to perform declaration emit. It doesn't fully solve the problem of enabling a fully parallel type-checking build across projects - but maybe that is a good middle-ground for some people, especially if they're willing to mostly annotate across projects. I think this sort of build approach is something that technically could be done today with our APIs. |
Interesting. I'm wondering how much performance do we gain if TSC implements this with the incremental builds. This is quite important as it has a minimal impact on the existing source codes. Referring to this:
This can be done separately outside of this proposal. If the problem of parallelizing the type checker is not having exported inferred types, can't we solve this in a more elegant way to avoid impacting the developer experience? First but probably the most naive solution is to keep the Another solution would be using an auto-inferred type annotation syntax. An example scenario that might help visualize the flow (syntax aside): // step #1 - user saves
export function A(): ? {
return 'validation' as const;
}
// step #2 - file saved
export function A(): ?'validation' {
return 'validation' as const;
}
// step #3 - user saves
import {x} from 'external-validation';
export function a<T extends string>(prop: T): ?'validation' {
return {
validation: x(prop),
};
}
// step #4 - file saved
import {x} from 'validation'; // declared function x<T>(value: T): ComplexType<T>
export function a<T extends string>(prop: T): ?{ validation: ComplexType<T>} {
return {
validation: x(prop),
};
} |
Isolated Declarations with Unified Emita.k.a Isolated Declarations (Take Two) Here's a brief update on where we are with the project after taking a change of approach at the end of January. Previous ApproachThe implementation of Isolated Declarations in #53463 intentionally avoided changing existing declaration emit. Keeping the new emit independent seemed like a good way to de-risk introduction of the feature, but led to various points of unfortunate duplication. This was most visible in the high volume of duplicate tests that dominated the size of the PR. Current ApproachThe feedback was that TypeScript's existing declaration emit should be enhanced to match the new emit produced by an isolated emitter where possible. So we are enhancing the classic declaration emit process to attempt a more verbatim extraction of the type declarations from the source files, and only falling back to multi-file crawling via the checker when isolated extraction fails. Crucially it means there will only be one declaration emit for a given source file. This simplifies the implementation, significantly reduces the testing burden, and allows third-party declaration emitters to use TypeScript's declaration emit as the reference target they need to match. Once this in-place declaration emit upgrade is complete, we shall add the Delivery PlanThere are a series of changes to unify declaration emit labeled
|
@robpalme thanks for all the work you've put into this. Do you folks have any idea if we can auto-fix returned inferred types in a project using a script? |
100% of errors reported by @dragomirtitian created an interactive CLI script (not public yet) to convert a whole codebase using the quickfixes. Where more than one quickfix is offered, it allows the user to select which one to apply. Sometimes you need a human with taste to pick the best choice. |
https://github.com/microsoft/ts-fix should also work. |
@jakebailey, Tried to build, and run that locally with the isolated declaration branch, and the whole process of building them locally, and make
|
Thanks to all the folks who worked on this - but a special shout-out to @dragomirtitian who put in a ton into making this happen in TypeScript 5.5! 🎉🎉🎉 |
Suggestion
TypeScript supports relying on type inference to produce (parts of) the API of a module. E.g. users can write (sorry, slightly contrived):
Note how you need to specify the type of
x
(otherwise it degenerates toany
), but can leave out the return type oflengthOf
if you like. TypeScript will infer the type, potentially using type information from the file's (transitive) dependencies.This causes two problems.
Readability. It is difficult to understand what the return type of
countParts
will be. This is purely a stylistic issue that could be fixed with a lint check (though there is some complexity e.g. due totypeof
).Compilation performance/parallelism.
Imagine you're using project references, and you have a dependency structure:
To compile, we need to first compile
textutils/splitter
, wait for that to complete, e.g. 5s, then compilecounter
, wait e.g. 3s, then compileapp
(6s). Total compilation wall time is|app| + |counter| + |textutils/splitter|
, in our example5s + 3s + 6s = 16 seconds
.Now assume we could produce
.d.ts
files without requiring transitive inputs. That'd mean we could, in parallel, produce the.d.ts
files fortextutils/splitter
,counter
(andapp
, though we don't need that). After that, we could, in parallel, type check and compiletextutils/splitter
,counter
, andapp
. Assuming sufficient available parallelism (which seems reasonable, given how common multicore CPUs are), total compilation wall time is the maximum time to extract.d.ts
files, plus the time for the slowest compile. Assuming.d.ts
extraction is purely syntactical, i.e. does not need type checking nor symbol resolution, it shouldn't add more overhead than a few hundred ms. Under these assumptions, the wall time to wait for the project to compile would be500 ms + 6s = 6.5 seconds
, i.e. more than a x2 speedup.The problem with that is that we cannot produce
.d.ts
files without running full type checking, I believe purely due to type inference.RFC thus: I wonder if this would sufficiently motivate the ability to restrict using type inference in exported API?
E.g. we could have a
noTypeInferenceOnExports
compiler flag, that would allow TypeScript to parallelise emitting.d.ts
across project references, and then parallelize type checking.The counter point is that projects that experience slow builds in edit refresh situations might instead want to turn off type checking entirely for their emit, at least on critical paths. However that means users do not see compilation results, and produces additional complexity (e.g. when and how to report type checking failures).
Impact
We've run some statistics internally at Google on build operations.
As one would expect, this change has little impact on most incremental "hot inner loop" builds, as those typically just re-type check a single file and produce no
.d.ts
change at all, so caching saves us from long build chains. We're seeing ~20% wall time improvements in the 90th percentile across all builds involving TypeScript.However the impact on slower builds is more substantial. For a sample "large" project that sees both slow individual compiles and a long dependency chain, we see ~50% improvement in the 90th percentile, and 75% in the 99th percentile (which is representative of CI style "cold" builds with little caching).
🔍 Search Terms
performance compilation parallelism inference declarations
✅ Viability Checklist
My suggestion meets these guidelines:
The text was updated successfully, but these errors were encountered: