Lesson 11 / 14
Transpilation and Targeting
The split between syntax downleveling and capability polyfilling, determining a target set, what transpilation cannot solve, and cases where the source language differs from the target language.
Contents
The previous lesson merged modules into a single output and assumed the syntax the source was written in would be recognized in the target environment. That assumption does not always hold.
The transform that turns the source into a form the target understands is called transpilation. Transpilation looks like one job; it actually solves two separate problems, and solving one does not solve the other.
Two Separate Problems
The syntax problem sits at the parsing level. If the target does not recognize the notation the source uses, it cannot even read the file; the error appears before a single line runs. The fix is expressing the same meaning with constructs the target does recognize.
The capability problem sits at the run-time level. The target can parse the file, but a function it calls or an object it accesses may not exist there. The error appears once execution reaches that line. The fix is defining the missing capability at run time.
The practical consequence of this split is that the first problem is solved at build time, the second at run time. One tool can do both, but what each produces is different — one rewrites the source, the other adds code to the output.
Syntax Downleveling
Downleveling is re-expressing a construct with the same meaning in an older notation. A class from the measurement library uses three separate features: a private field, an accessor, and an arrow function defined in field position.
// file: source.mjs export class Measurement { #words; constructor(words) { this.#words = words; } get count() { return this.#words.length; } longest = () => this.#words.reduce((longest, s) => (s.length > longest.length ? s : longest), ''); } const measurement = new Measurement(['one', 'second', 'third-word']); console.log(measurement.count, measurement.longest());
The same meaning, in a form that uses none of these three features:
// file: target.js 'use strict'; var _words = new WeakMap(); function Measurement(words) { var self = this; _words.set(this, words); this.longest = function () { return _words.get(self).reduce(function (longest, s) { return s.length > longest.length ? s : longest; }, ''); }; } Object.defineProperty(Measurement.prototype, 'count', { get: function () { return _words.get(this).length; }, }); var measurement = new Measurement(['one', 'second', 'third-word']); console.log(measurement.count, measurement.longest());
$ node source.mjs
3 third-word
$ node target.js
3 third-word
$ wc -c source.mjs target.js
377 source.mjs
543 target.js
920 total
The behavior is the same, the size is nearly half again as large. Downleveling is not free, and its cost has three items.
Size. Every downleveled construct is written out as a longer equivalent.
Performance. Faking a private field with a weak map is more expensive than direct field access. The downleveled equivalents of some class-syntax constructs fall outside the paths the engine optimizes.
Semantic approximation. The imitation is not exact. In the equivalent above, the
#words field is not actually inaccessible; code that reaches the map named _words
can read it. The private-field guarantee introduced in the Objects and Functions in
JavaScript course does not survive downleveling.
Capability Polyfilling
The fix for the second problem is defining the missing capability at run time; this is called a polyfill. The correct pattern is testing for the capability first and defining it only when it is absent.
Actually testing the pattern needs an environment where the capability is missing. The runtime’s isolated-context facility is useful for producing exactly such an environment, by removing a capability from it:
// file: polyfill.mjs import vm from 'node:vm'; const oldEnvironment = vm.createContext({ console }); // We simulate an old runtime by removing the capability from the context. vm.runInContext('delete Array.prototype.at;', oldEnvironment); vm.runInContext('console.log("before polyfill:", typeof [].at);', oldEnvironment); const polyfill = ` if (typeof Array.prototype.at !== 'function') { Object.defineProperty(Array.prototype, 'at', { value: function (index) { const n = Math.trunc(index) || 0; return this[n < 0 ? this.length + n : n]; }, writable: true, configurable: true, enumerable: false, }); } `; vm.runInContext(polyfill, oldEnvironment); vm.runInContext('console.log("after polyfill:", typeof [].at, [10, 20, 30].at(-1));', oldEnvironment); // The main context was not affected. console.log('main context:', typeof [].at, [10, 20, 30].at(-1));
$ node polyfill.mjs before polyfill: undefined after polyfill: function 30 main context: function 30
Three details are part of the pattern. The test is done with the capability itself, not a version query: what is asked is whether the function being looked for exists, not what the environment’s identity is. The definition uses a property descriptor instead of a plain assignment, so enumerability stays off and a built-in method’s behavior is imitated. Finally, because a polyfill modifies a built-in object, it affects the whole program — which is why polyfills are loaded once, at the entry point, not scattered through library modules.
A library should not modify the built-ins of the environment that consumes it. The correct behavior for a library is meeting the missing capability with a helper function of its own; the decision to modify a built-in belongs to the application.
The Target Set
What decides which constructs get downleveled and which polyfills get added is the target set: the list of environments the output is expected to run in. As the set widens, more downleveling happens, the output grows, and performance drops.
Two mistakes are common when determining the set. The first is keeping the set wider than it needs to be: the size and performance paid for environments no one actually uses is a cost borne by real users. The second is not writing the set down at all — a default list may not match the project’s actual users.
The right approach is keeping the set written down and justified. The set is a contract that states which environments the output will run in; the output is not expected to work outside that contract. Library packages should document this information, because a consumer’s target set has to cover the library’s own set.
What Transpilation Cannot Do
Transpilation’s limits are targeting’s limits.
Engine primitives cannot be imitated. A new primitive type, a structure tied to the garbage collector, or a capability that needs a particular memory layout cannot be written in library code. A polyfill for such a capability is either approximate or does not exist at all.
Semantic differences remain. As seen in the private-field example, the downleveled equivalent may not give the same observable behavior. The differences usually show up in edge cases and may not be caught by tests.
Performance does not come back. Downleveled code cannot draw on the target’s native support.
These three limits show that targeting is a trade-off: a wider set means more users, and a larger, slower output. The decision should be made by measurement.
When the Source Language Differs
Transpilation’s scope is not limited to version differences. The source can be a different language from the target altogether; the tool reads that language and produces output the target can run. In that case the transform does two jobs: meeting the constructs specific to the source language, and stripping the extra information the source language carries out of the output.
The most common example is type information: types written in the source are checked at build time and never written to the output. This is the cheapest form of the transform — the output is just the source with its type annotations stripped. This is where the connection lands in the course’s last lesson.
The divergence between source and output is the real problem this lesson leaves open. When an error produces a stack trace, the line numbers in that trace belong to the output, not the source. The more extensive the transpilation, the farther apart these two files are.
Summary
- Transpilation solves two separate problems: syntax the target does not recognize and a capability absent from the target; the first is solved at build time, the second at run time.
- Syntax downleveling grows the output, lowers performance, and turns some semantic guarantees into approximations.
- A polyfill is conditioned on the capability’s presence, not a version query; because it modifies built-ins, it belongs at the application’s entry point, not in libraries.
- The target set is a written contract stating which environments the output will run in; keeping it wider than needed costs real users.
- Engine primitives cannot be imitated, semantic differences remain, and downleveled code cannot reach native support’s performance.
Next Step
The output is now a file different from the source: its lines have shifted, its constructs have changed, its names may have been rewritten. When an error occurs, the line number the developer sees belongs to the generated file. The next lesson covers the mapping file that carries a generated position back to its source position, and how that file gets produced.
To keep your progress and take notes, Log in
My notes
Log in to take notes.