DEV Community

Evgen Mykhailenko
Evgen Mykhailenko

Posted on AI-assisted

TypeScript 7.1 Can Now Import Almost Anything. So I Made It Import C++, C, and Rust.

I've been following the development of Content Mappers in TypeScript for quite a while.

Partly because I'm excited about what they make possible, but also because I've been working on another TypeScript extension project that relies heavily on Volar.js.

And God, that's a lot of code to maintain just to get all the language features working correctly and keep everything in sync with TypeScript.

But with TypeScript 7.1, we finally have an official extension mechanism built directly into the compiler.

And I think this opens up some pretty interesting possibilities.

What if TypeScript could import anything?

Imagine being able to write something like this:

import { Rows, Columns } from "./data.csv";
import { exif } from "./photo.jpeg";
import { config } from "./config.yml";
Enter fullscreen mode Exit fullscreen mode

And not just import these files as strings or any, but have TypeScript actually understand their contents.

Imagine getting autocomplete for your YAML configuration, properly inferred columns from a CSV file, or type information extracted directly from another file format.

No manually maintained .d.ts files. No generic declare module "*.csv" that treats every CSV file identically.

Just types derived from the actual content.

This is the kind of thing Content Mappers make possible.

And it's not entirely theoretical. Motherload, from Colin McDonnell, is already exploring typed imports for several formats. Same for his another work - Reylo, it also utilizes the same approach.

But how does this actually work?

How TypeScript 7.1 Content Mappers work

Until now, TypeScript tooling has mostly been built around TypeScript and JavaScript files.

Frameworks like Vue, Svelte, and Astro needed additional tooling to make TypeScript understand code embedded inside their custom file formats.

That's where projects like Volar.js came in.

Volar made it possible to provide TypeScript language features inside non-TypeScript files, including autocomplete, diagnostics, go-to-definition, and references.

However, integrating this with TypeScript's compiler and language server wasn't exactly straightforward.

TypeScript 7.1 introduces Content Mappers as a first-class extension point.

The idea is surprisingly simple:

  1. Register a Content Mapper for one or more file extensions.
  2. When TypeScript encounters a matching file, it asks the mapper to process it.
  3. The mapper produces a virtual TypeScript representation of the file.
  4. TypeScript uses that representation for type checking and language services.

But here's the really interesting part.

The mapper can also provide source mappings between the virtual TypeScript code and the original file.

So TypeScript doesn't just understand the generated declarations. It can connect them back to their original source locations.

That means things like hover information, diagnostics, go-to-definition, references, and rename can work across file formats.

For example, imagine a Content Mapper that processes this C++ function:

// math.cpp
int add(int a, int b) {
    return a + b;
}
Enter fullscreen mode Exit fullscreen mode

It could expose the following virtual TypeScript declaration:

export declare function add(
    a: number,
    b: number
): number;
Enter fullscreen mode Exit fullscreen mode

Now TypeScript knows the function exists, what arguments it accepts, and what it returns.

And because the mapper knows where add, a, and b came from in the original C++ source, editor navigation can work across the language boundary.

Pretty cool, right?

Of course, Content Mappers don't automatically make arbitrary files executable. They solve the TypeScript understanding and tooling side of the problem.

Actually running those imports is still the responsibility of the runtime or another loader.

And that's where I started thinking about another problem.

What about native code?

I'm currently building a small inference engine, and some of its functionality lives in C++.

One of my biggest headaches has been integrating that native code with Node.js.

Normally, if you want to call a C++ function from Node.js, you need to create native bindings using Node-API (N-API).

It works, but it also means writing extra glue code, compiling addons, keeping TypeScript declarations in sync, and dealing with the build process.

There are libraries like Koffi that make native interoperability easier, and they're definitely interesting.

But I wanted something slightly different.

I wanted the native source itself to be the source of truth.

What if TypeScript could read C++ function signatures directly?

What if I could get IntelliSense, type checking, and go-to-definition without maintaining another layer of definitions?

And what if the native bindings could be generated and compiled automatically?

Essentially:

What if importing a C++ function felt exactly like importing another TypeScript function?

Well, I decided to try building it.

Introducing ts-polyglot

ts-polyglot is an experimental project that lets you import functions from C, C++, and Rust source files directly into TypeScript, with inferred types and native execution through Node.js.

Here's what that looks like.

First, a regular C function:

// add.c
int add(int a, int b) {
    return a + b;
}
Enter fullscreen mode Exit fullscreen mode

A C++ function:

// greet.cpp
#include <string>

std::string greet(std::string name) {
    return "Hello, " + name;
}
Enter fullscreen mode Exit fullscreen mode

And a Rust function:

// square.rs
pub fn square(n: i32) -> i32 {
    n * n
}
Enter fullscreen mode Exit fullscreen mode

Now, from TypeScript:

import { add } from "./add.c";
import { greet } from "./greet.cpp";
import { square } from "./square.rs";

console.log(add(2, 3));          // 5
console.log(greet("TypeScript")); // Hello, TypeScript
console.log(square(6));          // 36
Enter fullscreen mode Exit fullscreen mode

That's it.

No handwritten N-API wrappers. No manually maintained TypeScript declarations. No separate native build command.

And these aren't untyped calls.

TypeScript understands the imported signatures:

add(1, 2);       // ✅
add("hello", 2); // ❌ TypeScript error

square(5);       // ✅
square(true);    // ❌ TypeScript error
Enter fullscreen mode Exit fullscreen mode

You also get IntelliSense, hover information, and navigation to the original native source.

But functions are only part of the story.

We can import native structs too

One thing I really wanted was to avoid manually recreating C++ data structures in TypeScript.

Consider this simple C++ file:

// geometry.cpp

struct Point {
    double x;
    double y;
};

double distanceSquared(Point point) {
    return point.x * point.x +
           point.y * point.y;
}
Enter fullscreen mode Exit fullscreen mode

With ts-polyglot, we can import both the function and the native struct type:

import {
    distanceSquared,
    type Point
} from "./geometry.cpp";

const point: Point = {
    x: 3,
    y: 4
};

console.log(distanceSquared(point)); // 25
Enter fullscreen mode Exit fullscreen mode

TypeScript understands Point automatically.

Under the hood, the Content Mapper generates the equivalent of:

export interface Point {
    x: number;
    y: number;
}

export declare function distanceSquared(
    point: Point
): number;
Enter fullscreen mode Exit fullscreen mode

But you never have to write or maintain that interface yourself.

Change the native struct, and the generated TypeScript representation changes with it.

This also works with supported nested structs and array fields.

And importantly, Point isn't just a type-checking trick. When you call distanceSquared(point), ts-polyglot converts the JavaScript object into the native struct expected by C++.

Which brings us to the more interesting implementation details.

How do we discover which native functions can be imported?

This was one of the first interesting challenges.

C, C++, and Rust have very different syntax, and simply searching for function definitions with regular expressions obviously isn't going to work.

We need to understand the structure of the source code.

For that, ts-polyglot uses Tree-sitter.

Tree-sitter is a parser system that can generate syntax trees for many programming languages.

We use its C, C++, and Rust grammars to parse native source files and find declarations that we can expose to TypeScript.

The process looks roughly like this:

1. Parse the native source

When ts-polyglot encounters a native file, it passes the source through the appropriate Tree-sitter parser.

Tree-sitter gives us a syntax tree containing function definitions, parameter declarations, return types, structs, and other language constructs.

For example:

struct Point {
    double x;
    double y;
};

double distanceSquared(Point point) {
    return point.x * point.x +
           point.y * point.y;
}
Enter fullscreen mode Exit fullscreen mode

From this, we can extract information equivalent to:

Struct: Point
  x: double
  y: double

Function: distanceSquared
  Parameters:
    point: Point
  Returns:
    double
Enter fullscreen mode Exit fullscreen mode

The important thing is that we're reading the actual native source, not a separate declaration file.

2. Find supported functions

Next, we traverse the syntax tree looking for functions that can be imported.

It's not limited to simple top-level functions.

For example, ts-polyglot can discover supported C++ functions inside namespaces:

namespace math {
    double square(double x) {
        return x * x;
    }
}
Enter fullscreen mode Exit fullscreen mode

It also handles functions inside extern "C" blocks and public functions inside Rust modules.

But finding a function isn't enough.

We also need to determine whether its parameters and return type can actually cross the JavaScript/native boundary.

A function that accepts a double is relatively straightforward.

A function that accepts an arbitrary pointer, a complicated template, or a mutable reference is a very different problem.

So before exposing a function, we inspect its signature and check whether every parameter and the return value have a supported type mapping.

If the function contains unsupported types, we skip it rather than generating a TypeScript declaration that we can't actually support at runtime.

This means you can have a native source file containing many different functions, and ts-polyglot will expose the subset it knows how to handle.

3. Resolve native types into TypeScript types

Once we know a function can be imported, we generate its TypeScript signature.

For example:

bool isPositive(double value);
Enter fullscreen mode Exit fullscreen mode

Becomes:

export declare function isPositive(
    value: number
): boolean;
Enter fullscreen mode Exit fullscreen mode

For structs, we inspect the fields and recursively map their types too.

This is where things become more complicated, because native types don't always have a direct JavaScript equivalent.

And that leads to the next big topic.

Type mapping: C++ types aren't JavaScript types

TypeScript has number, string, boolean, bigint, arrays, objects, and a few other useful abstractions.

C, C++, and Rust have much richer and sometimes very different type systems.

So we need explicit rules for translating native types into TypeScript types.

Here's a simplified overview of what ts-polyglot currently supports:

Native type TypeScript type
int, int32_t, Rust i32 number
float, double, Rust f64 number
bool boolean
std::string, Rust String string
int64_t, Rust i64 bigint for results
struct Point interface Point
std::vector<double>, Vec<f64> number[]
Unsigned byte arrays Uint8Array
Fixed-size arrays TypeScript tuples

There are some important details here.

For example, 64-bit integers are represented as bigint for return values and struct fields, while parameters can accept number | bigint where supported.

Array parameters are typed as readonly arrays, while returned arrays are mutable.

And some native types have no safe mapping yet.

You can find the full table of supported mappings here:

https://ts-polyglot.dev/#types

But generating TypeScript types is only half of the problem.

At runtime, we also need to convert values in both directions.

Type marshaling: getting values across the native boundary

This is probably one of the trickiest parts of native interoperability.

When TypeScript calls a C++ function, JavaScript values can't simply be passed directly into native code.

They have to be converted into their corresponding native representations.

This process is called type marshaling.

Let's go back to our Point example:

const point: Point = {
    x: 3,
    y: 4
};

distanceSquared(point);
Enter fullscreen mode Exit fullscreen mode

From the TypeScript side, this is just a plain JavaScript object.

But our C++ function expects a native Point struct.

So the generated Node-API binding needs to do something conceptually like this:

  1. Receive the JavaScript object.
  2. Verify that it has the expected properties.
  3. Read x and y and validate their types.
  4. Convert both values into native double values.
  5. Construct a native Point struct.
  6. Call the actual C++ function.
  7. Convert the returned double back into a JavaScript number.

And we do the opposite when a native function returns a struct.

For example:

Point createPoint(double x, double y) {
    return { x, y };
}
Enter fullscreen mode Exit fullscreen mode

From TypeScript:

import {
    createPoint,
    type Point
} from "./geometry.cpp";

const point: Point = createPoint(10, 20);

// { x: 10, y: 20 }
Enter fullscreen mode Exit fullscreen mode

The generated binding reads the native struct's fields and creates a new JavaScript object.

No manually written conversion code.

The same principle applies to supported strings, arrays, nested structs, and other types.

There are some important safety details too.

Conversions are checked at runtime.

If you pass a string to a function that expects an integer, the generated binding throws a TypeError.

If you pass a number outside the native integer's supported range, it throws a RangeError.

Arrays and structs cross the boundary by value, with their contents copied. JavaScript objects aren't simply reinterpreted as native memory.

This is important because JavaScript and native code have very different memory models and ownership rules.

We want an import to feel like a normal TypeScript function call without silently introducing unsafe conversions.

Some mappings are smarter than simple type replacement

One example I particularly like is C arrays.

Consider:

double mean(const double values[], size_t length) {
    double total = 0;

    for (size_t i = 0; i < length; i++) {
        total += values[i];
    }

    return length ? total / length : 0;
}
Enter fullscreen mode Exit fullscreen mode

Normally, you'd need to pass both the array and its length.

But ts-polyglot can recognize supported C array-and-length parameter patterns.

So the exposed TypeScript signature becomes:

export declare function mean(
    values: readonly number[]
): number;
Enter fullscreen mode Exit fullscreen mode

And you can call it like this:

mean([10, 20, 30]); // 20
Enter fullscreen mode Exit fullscreen mode

The generated binding takes care of calculating and passing the length.

It's a small example, but it demonstrates that type mapping doesn't have to be just a one-to-one translation of native types.

We can expose a more natural TypeScript API while preserving the original native function.

From source file to working native import

Now we have two important pieces:

  • Function discovery and type mapping, which tell TypeScript what functions exist and how to use them.
  • Type marshaling, which tells the runtime how to safely call those functions.

Here's how ts-polyglot connects everything.

At development time: the Content Mapper

When TypeScript encounters:

import {
    distanceSquared,
    type Point
} from "./geometry.cpp";
Enter fullscreen mode Exit fullscreen mode

Our Content Mapper uses Tree-sitter to parse the C++ file, discover supported functions and structs, and generate their virtual TypeScript declarations.

It also returns source mappings so TypeScript can associate symbols in those declarations with their original positions in the C++ file.

Nothing needs to be compiled at this stage.

TypeScript just gets the information it needs for type checking and editor features.

At runtime: the Node.js import hook

When Node.js encounters the same import, a different part of ts-polyglot takes over.

The runtime hook:

  1. Parses the native source using the same signature discovery implementation.
  2. Generates Node-API bindings for the supported functions.
  3. Generates the necessary type marshaling code.
  4. Compiles the native code and generated bindings into a .node addon.
  5. Loads the addon and exposes its functions as JavaScript exports.

For Rust, the build process also involves compiling the Rust code into a static library and linking it with the generated addon.

The resulting builds are cached, so subsequent imports of unchanged source files don't need to compile everything again.

And there's an important detail here:

Both the Content Mapper and runtime use the same Tree-sitter-based signature parser.

So the types TypeScript checks are derived from the same native signatures that the runtime uses when generating bindings.

This is especially useful because otherwise we'd effectively be maintaining two different interpretations of the same native code.

The goal is to have a single source of truth: the native source file itself.

Try it yourself

The easiest way to create a sample project is:

npm create ts-polyglot
Enter fullscreen mode Exit fullscreen mode

This scaffolds a project with C, C++, and Rust examples.

For an existing project, install:

npm install ts-polyglot
npm install -D typescript@7.1.0-beta
Enter fullscreen mode Exit fullscreen mode

Then register the Content Mapper in tsconfig.json:

{
  "compilerOptions": {
    "module": "nodenext",
    "strict": true,
    "noEmit": true
  },
  "contentMappers": [
    {
      "package": "ts-polyglot",
      "extensions": [".c", ".cpp", ".rs"]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Type-check your project:

npx tsc --runExternalCode
Enter fullscreen mode Exit fullscreen mode

The --runExternalCode flag is required because Content Mappers are external executable programs.

And run your TypeScript file with the native import hook:

node --import ts-polyglot/register main.ts
Enter fullscreen mode Exit fullscreen mode

You'll need Node.js 22.18+, a native C/C++ compiler, and rustc if you're importing Rust.

For VS Code, use the TypeScript (Native Preview) extension, select your workspace's TypeScript 7.1 version, and enable the mapper in a trusted workspace.

It's still early

This is very much an experimental project.

There are lots of interesting problems we haven't solved yet.

Native interoperability gets complicated quickly, especially when pointers, ownership, lifetimes, mutable references, callbacks, and more complicated C++ and Rust constructs enter the picture.

At the moment, ts-polyglot focuses on supported functions with well-defined mappings for numbers, booleans, strings, plain structs, and arrays.

Things like arbitrary pointers, mutable out-parameters, enums, function pointers, and various templates and generics aren't supported yet.

Tree-sitter also isn't a full C++ compiler. It doesn't expand macros or perform complete semantic analysis, so there are limits to what we can discover directly from the source.

And TypeScript 7.1's Content Mapper protocol is still in beta, so some integration details may change.

But that's part of the experiment.

How much native binding boilerplate can we eliminate if TypeScript understands the source directly and the runtime can generate the bridge automatically?

Why I think Content Mappers matter

What excites me most about TypeScript 7.1 isn't just support for another file extension.

It's the possibility of making TypeScript's type system and development tooling available to entirely different languages and file formats through a supported extension mechanism.

Today we're talking about Vue components, CSV files, YAML configurations, and native functions.

But the same concept could be applied to many other kinds of source files and domain-specific languages.

We can start treating these files as first-class participants in a TypeScript project instead of maintaining separate declarations or building completely separate language tooling around them.

And with an appropriate runtime integration, those imports can become executable too.

For me, ts-polyglot is one experiment in that direction.

It's still early, and there's plenty of work ahead, but I think there's something really interesting here.

If you've worked with TypeScript language tooling, Volar, Node-API, native interoperability, Tree-sitter, or Content Mappers, I'd love to hear what you think.

And if you want to try it or explore the implementation:

GitHub: https://github.com/mykhailen/ts-polyglot

Website: https://ts-polyglot.dev/

Supported types: https://ts-polyglot.dev/#types

TypeScript 7.1 announcement: https://devblogs.microsoft.com/typescript/announcing-typescript-7-1-beta/

Feedback, ideas, and contributions are welcome!

Top comments (0)