---
title: 'Transpilation and Targeting'
source: 'https://academia.sh/en/courses/javascript-ecosystem/transpilation-and-targeting'
course: 'Modules, Tooling and the Ecosystem'
language: en
updated: '2026-08-17T18:09:45+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```javascript
// 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:

```javascript
// 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:

```javascript
// 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.
