What is TypeScript? An overview for JavaScript programmers

[2025-02-27] dev, typescript
(Ad, please don’t block)

Read this blog post 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:

  • How is TypeScript code different from JavaScript code?
  • How is TypeScript code run?
  • How does TypeScript help during editing in an IDE?
  • Etc.

Note: This blog post does not explain why TypeScript is useful. If you want to know more about that, you can read my TypeScript sales pitch.

TypeScript is JavaScript plus type syntax  

Even though the following description of TypeScript is not 100% accurate (there are a few exceptions), I find it helpful for figuring out how it works: TypeScript is JavaScript plus type syntax.

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;
}

The type syntax is only used for type checking (during editing and compiling) and gives us many consistency checks and better auto-completion.

Ways of running TypeScript code  

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.
  • The remaining files are TypeScript source code.

Let’s explore the different ways in which we can run this code.

Running TypeScript directly  

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

Bundling TypeScript  

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:

  • Prior to HTTP/2, only one file could be served per connection. But that benefit of bundling is not relevant anymore.
    • Each file the client has to request and process, still incurs a little overhead (even though no new connection is opened).
  • Web servers don’t have to serve many (often small) files – which helps with efficiency.
  • A single large file can be compressed better than many small files.

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

Transpiling TypeScript to JavaScript  

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 determines 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

The filename extensions of locally imported TypeScript modules  

By default, TypeScript does not change the specifiers of imported modules. That means that local imports of transpiled code must look like this:

// main.ts
import {helperFunc} from './util.js';

However, we can also tell TypeScript to rewrite the filename extension .ts to .js (more information). Then the following import works for both directly running our code and transpiling it:

// main.ts
import {helperFunc} from './util.ts';

Publishing a library package to the npm registry  

The npm registry is still the most popular way of publishing packages. Even though Node.js supports app packages being written in TypeScript, library packages must be deployed as JavaScript code – so that they can be consumed by either JavaScript or TypeScript. Therefore, a single library file lib.ts is often deployed as five files (four of which are compiled by TypeScript from lib.ts):

  • Essential:
    • lib.js: the JavaScript part of lib.ts
    • lib.d.ts: the type part of lib.ts
  • Optional: source maps. They map source code locations of compilation output to 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/
    src/
      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.
  • Every file in dist/ was generated by TypeScript. It is usually not added to version control systems because it can easily be regenerated.
  • Only tsconfig.json is not uploaded to the npm registry.

Essential: .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:

  • JavaScript code can ignore .d.ts files.
  • TypeScript uses them for type checking, auto-completion and documentation.

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:

  • Both files point to their source maps.
  • By default, both files contain comments (but we can tell TypeScript not to include them):
    • 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.

Optional: source maps  

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.

DefinitelyTyped: a repository with types for type-less npm packages  

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.

Compiling TypeScript with tools other than tsc  

Let’s recap all the tasks performed by tsc (we’ll ignore source maps in this section):

  1. It compiles TypeScript files to JavaScript files.
  2. It compiles TypeScript files to type declaration files.
  3. It type-checks TypeScript files.

#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  

Type stripping is the simplest way of compiling TypeScript to JavaScript:

  • Compilation consists only of removing type syntax.
  • No language-level features are transpiled.

The second point means that several TypeScript features are not supported – e.g.:

  • JSX (HTML-ish syntax inside TypeScript, as used, e.g., by React)
  • Enums
  • Parameter properties in class constructors.
  • Namespaces
  • Future JavaScript that is compiled to current JavaScript

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.

Type stripping technique: replacing types with spaces  

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 declarations  

“Isolated declaration” is a style of writing TypeScript where the types for a declaration file (.d.ts) are easy to extract. That mainly means providing return types for functions that are exported. In principle, TypeScript can figure those out for us, but simple declaration file generators can’t. This constraint does not exist for unexported functions because those don’t show up in declaration files.

First version of a TypeScript file strings.ts:

// Not OK: exported function without return type
export function upperCase(str: string) {
  return str.toUpperCase();
}

// Not exported, no return type needed
function internalHelper() {}

strings.ts in isolated declaration style:

// OK: exported function has return type
export function upperCase(str: string): string {
  return str.toUpperCase();
}

// Not exported, no return type needed
function internalHelper() {}

This is the generated declaration file strings.d.ts (note that internalHelper is not in it):

export declare function upperCase(str: string): string;

JSR – the JavaScript registry  

The JavaScript registry JSR is an alternative to npm and the npm registry for publishing packages. It works as follows:

  • For TypeScript packages, you only upload .ts files.
  • How to install a TypeScript package depends on the platform:
    • On JavaScript platforms where TypeScript-only library packages are supported, JSR only installs TypeScript.
    • On all other platforms, JSR automatically generates .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.

Who owns JSR?  

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.

Editing TypeScript  

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.

Type-checking JavaScript files  

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:

  • No need for a build step to run the code – even on platforms (such as browsers) that don’t support TypeScript.
    • We can also generate .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.
  • It enables us to make a JavaScript code base more type-safe – in small incremental steps.

Downside of this approach:

  • The syntax becomes less pleasant to use.

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:

Further reading