Read this chapter if you are a JavaScript programmer and want to get a rough idea of what using TypeScript is like (think first step before learning more details). You’ll get answers to the following questions:
This chapter focuses on how TypeScript works. If you want to know more about why it is useful, see “Sales pitch for TypeScript” (§2).
Let’s start with a rough first description of what TypeScript is. That description is not completely accurate (there are exceptions and details that I’m omitting), but it should give you a solid first idea:
TypeScript is JavaScript plus type syntax.
What are the purposes of these two parts?
The JavaScript syntax is what is run and what exists at runtime: In order to run TypeScript code, the type syntax must be removed – via compilation that results in pure JavaScript. That code is executed by a JavaScript engine.
The type syntax is only used during editing and compiling; it has no effect at runtime:
Consider the following TypeScript code:
function add(x: number, y: number): number {
return x + y;
}
If we want to run this code, we have to remove the type syntax and get JavaScript that is executed by a JavaScript engine:
function add(x, y) {
return x + y;
}
Consider the following TypeScript project:
ts-app/
tsconfig.json
src/
main.ts
util.ts
util_test.ts
test/
integration_test.ts
tsconfig.json
is a configuration file that tells TypeScript how to type-check and compile our code.
Let’s explore the different ways in which we can run this code.
Most server-side runtimes now can run TypeScript code directly – e.g., Node.js, Deno and Bun. In other words, the following works in Node.js 23.6.0+:
cd ts-app/
node src/main.ts
When developing a web app, bundling is a common practice – even for pure JavaScript projects: All the JavaScript code (app code and library code) is combined into a single JavaScript file (sometimes more, but never more than a few) – which is typically loaded from an HTML file. That has several benefits:
Most bundlers support TypeScript – either directly or via plugins. That means, we run our TypeScript code via the JavaScript file bundle.js
that was produced by a bundler:
ts-app/
tsconfig.json
src/
main.ts
util.ts
util_test.ts
test/
integration_test.ts
dist/
bundle.js
Another option is to compile out TypeScript app to JavaScript via the TypeScript compiler tsc
and run the resulting code. Before server-side JavaScript runtimes had built-in support for TypeScript, that was the only way we could run TypeScript there.
Compiling source code to source code is also called transpiling. tsconfig.json
specifies where the transpilation output is written. Let’s assume we write it to the directory dist/
:
ts-app/
tsconfig.json
src/
main.ts
util.ts
util_test.ts
test/
integration_test.ts
dist/
src/
main.js
util.js
util_test.js
test/
integration_test.js
When it comes to filename extensions of locally imported TypeScript modules, we must distinguish between code that is transpiled and code that is run directly.
By default, TypeScript does not change the specifiers of imported modules. Therefore, code that is transpiled must look like this (we import util.js
, from JavaScript code):
// main.ts
import {helperFunc} from './util.js';
However, such code does not work if we run it directly. There, we must write (we import util.ts
from TypeScript code):
// main.ts
import {helperFunc} from './util.ts';
We can also tell TypeScript to change the filename extensions of local imports from .ts
to .js
(more information). Then the previous code can also be transpiled.
The npm registry is still the most popular means of publishing packages. Even though Node.js runs TypeScript code, packages must be deployed as JavaScript code. That enables JavaScript code to use library packages written in TypeScript. However, we additionally want to support TypeScript features. Therefore, a single library file lib.ts
is often deployed as five files (four of which are compiled by TypeScript from lib.ts
):
lib.js
: the JavaScript part of lib.ts
lib.d.ts
: the type part of lib.ts
(a declaration file)
lib.ts
.
lib.js.map
: source map for lib.js
lib.d.ts.map
: source map for lib.d.ts
lib.ts
: the target of the previous two source maps
(More on what all of that means in a second.)
As an example, consider the following library package:
ts-lib/
package.json
tsconfig.json
src/
lib.ts
dist/
lib.js
lib.js.map
lib.d.ts
lib.d.ts.map
package.json
is npm’s description of our library package. Some of its data, such as the so-called package exports, are also used by TypeScript – e.g. to look up type information when someone imports from our package.
dist/
was generated by TypeScript. While it is uploaded to the npm registry, it is usually not added to version control systems because it can easily be regenerated.
tsconfig.json
is not uploaded to the npm registry.
.js
and .d.ts
It’s interesting to see the combined JavaScript plus types in .lib.ts
be split into lib.js
with only JavaScript and lib.d.ts
with only types. Why do that? It enables library packages to be used by either JavaScript code or TypeScript code:
.d.ts
files.
Actually, behind the scenes, many editors (e.g. Visual Studio Code) use a kind of lightweight TypeScript mode when editing JavaScript code so that we also get simple type checking and code completion there.
This is the TypeScript input lib.ts
/** Add two numbers. */
export function add(x: number, y: number): number {
return x + y; // numeric addition
}
It is split into lib.js
on one hand:
/** Add two numbers. */
export function add(x, y) {
return x + y; // numeric addition
}
//# sourceMappingURL=lib.js.map
And lib.d.ts
on the other hand:
/** Add two numbers. */
export declare function add(x: number, y: number): number;
//# sourceMappingURL=lib.d.ts.map
Notes:
lib.js
has all comments so that the code is easier to read.
lib.d.ts
only has JSDoc comments (/** */
) because they are used by many IDEs to display inline documentation.
If we compile a file I
to a file O
then a source map for O
maps source code locations in O
to source code locations in I
. That means we can work with O
but display information from I
– e.g.:
lib.js.map
: maps lib.js
locations to lib.ts
locations and gives us debugging and stack traces for the latter when we run the former.
lib.d.ts.map
: maps lib.d.ts
lines to lib.ts
lines. It enables “go to definition” for imports from lib.ts
to take us to that file.
All source-map-related functionality except stack traces require access to the original TypeScript source code. That’s why it makes sense to include lib.ts
if there are source maps.
This is what lib.js.map
looks like:
{
"version": 3,
"file": "lib.js",
"sourceRoot": "",
"sources": [
"../../src/lib.ts"
],
"names": [],
"mappings": "AAAA,uBAAuB;AACvB,MAAM,UAAU,···"
}
This is what lib.d.ts.map
looks like:
{
"version": 3,
"file": "lib.d.ts",
"sourceRoot": "",
"sources": [
"../../src/lib.ts"
],
"names": [],
"mappings": "AAAA,uBAAuB;AACvB,wBAAgB,GAAG,···"
}
In both cases, the actual content of "mappings"
was abbreviated. And in the actual output of tsc
, the JSON is always squeezed into a single line.
These days, many npm packages come with TypeScript types. However, not all of them do. In that case, DefinitelyTyped may help: If it supports a type-less package pkg
then we can additionally install a package @types/pkg
with types for pkg
.
One important DefinitelyTyped package for Node.js is @types/node
with types for all of its APIs. If you develop TypeScript on Node.js, you will usually have this package as a development dependency.
tsc
Let’s recap all the tasks performed by tsc
(we’ll ignore source maps in this section):
##3 is so complex that only tsc
can do it. However, for both #1 and #2, there are slightly simpler subsets of TypeScript where compilation does not involve much more than syntactic processing. That means that we can use external, faster tools for #1 and #2.
There are even tsconfig.json
settings to warn us if we don’t stay within those subsets of TypeScript (more information). Doing that is not much of a sacrifice in practice.
Type stripping is a simple and fast way of compiling TypeScript to JavaScript. It’s what Node.js uses when it runs TypeScript. Type stripping is fast because it only supports a subset of TypeScript where two things are possible:
Type syntax can be detected and removed by only parsing the syntax – without performing additional semantic analyses.
No non-type language features are transpiled. In other words: Removing the type syntax is enough to produce JavaScript.
#2 means that there are several TypeScript features that we can’t use – e.g., enums and JSX (HTML-like syntax inside TypeScript, as used, e.g., by React).
One considerable benefit of type stripping is that it does not need any configuration (via tsconfig.json
or other means) because it’s so simple. That makes platforms that use it more stable w.r.t. changes made to TypeScript.
One clever technique for type stripping was pioneered by the ts-blank-space
tool (by Ashley Claymore for Bloomberg): Instead of simply removing the type syntax, it replaces it with spaces. That means that source code positions in the output don’t change. Therefore, any positions that show up (e.g.) in stack traces still work for the input and there is less of a need for source maps: You still need them for debugging and going to definitions but JavaScript generated by type stripping is relatively close to the original TypeScript and you are often OK even then.
For example - input (TypeScript):
function add(x: number, y: number): number {
return x + y;
}
Output (JavaScript):
function add(x , y ) {
return x + y;
}
If you want to explore further, you can check out the ts-blank-space playground.
“Isolated declaration” is a style of writing TypeScript that makes it easier for external tools to generate declaration files. It mainly means we have to add type annotations in locations where the TypeScript compiler tsc
does not need them – thanks to its ability to automatically derive types (so-called type inference). However, external tools are simpler and faster if they don’t need that ability. Additionally, they don’t have to visit and analyze external files if the type information is provided locally.
The following example shows how the isolated declaration style changes code:
// OK: return type stated explicitly
export function f1(): string {
return 123..toString();
}
// Error: return type requires inference
export function f2() {
return 123..toString();
}
// OK: return type trivial to determine
export function f3() {
return 123;
}
Note that isolated declarations only affect constructs that are exported. Module-internal code does not show up in declaration files.
The JavaScript registry JSR is an alternative to npm and the npm registry for publishing packages. It works as follows:
.ts
files.
.js
files and .d.ts
files and installs those, along with the .ts
files. To make automatic generation possible, the TypeScript code must follow a set of rules called “no slow types” – which is similar to isolated declarations.
In contrast, with the npm registry, your TypeScript library package is only usable on Node.js if you upload .js
files and .d.ts
files.
JSR also provides several features that npm doesn’t such as automatic generation of documentation. See “Why JSR?” in the official documentation for more information.
Quoting the official documentation page “Governance”:
JSR is not owned by any one person or organization. It is a community-driven project that is open to all, built for the entire JavaScript ecosystem.
JSR is currently operated by the Deno company. We are currently working on establishing a governance board to oversee the project, which will then work on moving the project to a foundation.
Two popular IDEs for JavaScript are:
The observations in this section are about Visual Studio Code, but may apply to other IDEs, too.
With Visual Studio Code, we get two different ways of type checking:
Any file that is currently open is automatically type-checked within Visual Studio Code. It order to provide that functionality, it comes with its own installation of TypeScript.
If we want to type-check all of a code base, we must invoke the TypeScript compiler tsc
. We can do that via Visual Studio Code’s tasks – a built-in way of invoking external tools (for type checking, compiling, bundling, etc.). The official documentation has more information on tasks.
Optionally, TypeScript can also type-check JavaScript files. Obviously that will only give us limited results. However, to help TypeScript, we can add type information via JSDoc comments – e.g.:
/**
* @param {number} x - The first operand
* @param {number} y - The second operand
* @returns {number} The sum of both operands
*/
function add(x, y) {
return x + y;
}
If we do that, we are still writing TypeScript, just with a different syntax.
Benefits of this approach:
.d.ts
files from .js
files with JSDoc comments. That is an extra build step, though. How to do that is explained in the TypeScript Handbook.
Downside of this approach:
To explain the downside – consider how we define an interface in TypeScript:
interface Point {
x: number;
y: number;
/** optional property */
z?: number;
}
Doing that via a JSDoc comment looks like this:
/**
* @typedef Point
* @prop {number} x
* @prop {number} y
* @prop {number} [z] optional property
*/
More information in the TypeScript Handbook: