JavaScript Interface (JSI) Examples for React Native

Adonis Gaitatzis
14 min readApr 1, 2024

JavaScript Interface (JSI) offers a seamless bridge between JavaScript and native C++ code, enhancing performance by facilitating direct communication. This tutorial aims to guide you through creating and integrating JSI functions in React Native, covering a spectrum from basic to complex functionalities.

This tutorial shows some common features of TypeScript and how their corresponding JSI functions can be declared in C++.

The examples in this article are:

  • Setting up the JSI Framework
  • Example 1: Simple C++ Function with No Parameters and No Return
  • Example 2: Function Returning a String
  • Example 3: Functions Returning Other Simple Types
  • Example 4: Function Accepting Parameters
  • Example 5: Returning an Arbitrary Object
  • Example 6: Returning an Array
  • Example 7: Simple Types as Function Parameters
  • Example 8: Complex Types as Function Parameters
  • Example 9: Array Parameters
  • Example 10: Throwing Errors
  • Example 11: Asynchronous Functions
  • Example 12: Callbacks as a Parameter
  • Example 13: Callbacks with parameters in a Function Parameter

Prerequisites

  • Basic knowledge of C++, TypeScript, and React Native.
  • NodeJS and GCC Compiler installed on Linux or Windows, XCode or GCC on MacOS.
  • A React Native project

Initialize a new React Native Project

Here’s how to get started with a new React Native project. Although it isn’t required for JSI, we are adding TypeScript support.

npx react-native init YourProjectName --template react-native-template-typescript

Setting up the JSI Framework

Before Getting started writing functions, we should create our JSI interface framework. This framework is a C++ file that creates a name for our library, includes the JSI libraries, and registers the our library with the React Native Project.

Let’s say we have a library we want to call “exampleJsiLibrary.” We can set it up like this:

// include common C++ features such as String and cout
#include <iostream>
// include the facebook::jsi features
#include "jsi/jsi.h"

// describe which namespaces we are using,
// so we don't have to prefix each method with `facebook::jsi::...`
using namespace facebook::jsi;
using namespace std;

// create a JSI library and attach it to our React Native runtime environment
void installExampleJsiLibrary(Runtime &runtime) {
// ... define JSI functions here
// auto functionName1 = Function::createHostFunction(...);
// runtime.global()().setProperty(...);
// auto functionName2 = Function::createHostFunction(...);
// runtime.global()().setProperty(...);
// ...
}

Now let’s add some functions to this library.

Example 1: Simple C++ Function with No Parameters and No Return

In your React Native code, you may want to execute a function that performs some action and returns void.

For example, let’s say we have a JSI function called simplePrint() which echoes Hello World to the console.

TypeScript Call

The TypeScript call may look like this:

const executeBlindFunction = () => {
simplePrint();
}

JSI Declaration

The corresponding JSI function won’t take any parameters or return a value. Instead it will use cout to echo text to the console.


// ... includes and namespace

void exampleJsiLibrary(Runtime &runtime) {

// register our function
auto simplePrintFunction = Function::createFromHostFunction(
// React Native Runtime Context
runtime,
// define function name for TypeScript
PropNameID::forAscii(runtime, "simplePrint"),
// define number of parameters
0,
[](
Runtime &runtime, const Value &thisValue,
const Value *arguments,
size_t count
) -> Value
{
// echo to the console
cout << "Hello from C++" << endl;
// return a TypeScript-compatible `undefined` value
return Value::undefined();
}
);

// register the function to be accessible to TypeScript,
// something similar to declaring the function as `public`
runtime.global().setProperty(
runtime,
"simplePrint",
move(simplePrintFunction)
);

}
  • installExampleJsiLibrary function registers a JSI function in the JS runtime.
  • Function::createFromHostFunction: Creates a JSI function that executes native C++ code.
  • return Value::undefined(): Since the function doesn’t return any value, it returns undefined.
  • runtime.global().setProperty(...) : Tells JSI which functions can be called, similar to how object-oriented code uses public.

Example 2: Function Returning a String

Let’s say we have a JSI function called returnString() which returns Hello World back to the TypeScript call.

TypeScript Call

The TypeScript call may look like this:

const executeStringReturnFunction = () => {
const stringValue: string = returnString();
console.log(stringValue); // "Hello World"
}

JSI Declaration

To create a function that returns a string to JavaScript, we use String::createFromUtf8() or String::createFromAscii(). For example:

void exampleJsiLibrary(Runtime &runtime) {
// create a function the same way
auto returnString = Function::createFromHostFunction(
runtime,
PropNameID::forAscii(runtime, "returnString"),
0,
[](
Runtime &runtime,
const Value &thisValue,
const Value *arguments,
size_t count
) -> Value {
// return a `Value` that represents a string
return Value(
runtime,
// create a string from UTF-8 text
String::createFromUtf8(runtime, "Hello World")
);
}
);

// register the function as callable
runtime.global().setProperty(runtime, "returnString", move(returnString));
}

String::createFromUtf8() creates a JSI string from a C-string. This method ensures the returned string is correctly integrated into the JavaScript runtime, handling any necessary conversions and memory management.

Example 3: Returning Other Simple Types

You may want to return types other than string from your JSI code, for example number, null, or undefined.

In TypeScript, you may call these methods like this:

const otherTypesExample = () => {
const numberValue: number = getNumber();
const nullValue: null = getNull();
const undefinedValue: undefined = getUndefined():
}

To return these types in JavaScript Interface, you only need to use the Value object, for example:

void exampleJsiLibrary(Runtime &runtime) {
// number
auto getNumber = Function::createFromHostFunction(..., {
return Value(32);
});
runtime.global().setProperty(...);

// null
auto getNull = Function::createFromHostFunction(..., {
return Value();
});
runtime.global().setProperty(...);

// undefined
auto getNull = Function::createFromHostFunction(..., {
return Value::undefined();
});
runtime.global().setProperty(...);
}

Example 4: Function Accepting Parameters

Let’s say we have a JSI function called acceptParameters() which expects two number parameters to be passed into the function and then returns the sum as a number.

TypeScript Call

The TypeScript call may look like this:

const executeBlindFunction() {
const sum: number = acceptParameters(1, 2);
console.log("sum:", sum); // "sum: 3"
}

JSI Declaration

Creating a function that accepts parameters involves processing the `arguments` array passed into the lambda.

void exampleJsiLibrary(Runtime &runtime) {
auto sumNumbers = Function::createFromHostFunction(
runtime,
PropNameID::forAscii(runtime, “sumNumbers”),
// Two parameters are expected
2,
[](
Runtime &runtime,
const Value &thisValue,
// the arguments array becomes important here
const Value *arguments,
size_t count
) -> Value {
// get the two parameters and cast them as `number` types
double firstNumber = arguments[0].getNumber();
double secondNumber = arguments[1].getNumber();
double sum = firstNumber + secondNumber;
// construct a `number` value to return to TypeScript
return Value(sum);
}
);

runtime.global().setProperty(runtime, "sumNumbers", move(sumNumbers));

}

Explanation

  • arguments[0].getNumber(): Extracts a Typescript number from the first argument and casts it as a double in C++.
  • return Value(sum): works in sort of the opposite direction, taking the C++ double and returning it as a TypeScript compatible value (in this case a number

Example 5: Returning an Arbitrary Object

TypeScript handles complex object types really well, using JSON-formatted data. To return a complex object like a dictionary, construct a JSI `Object` and set its properties.

TypeScript Call

Let’s say we have a function called returnPerson(id) that returns a Person object with some id, name, age, and gender. The following function call in Typescript can be written like this:

type Person {
id: string;
name: string;
age: number;
gender: "male" | "female";
};

const lookupPerson() {
const personId = "123";
const person: Person = returnPerson(personId);
}

JSI Declaration

In order to return a complex TypeScript object in JSI, we must instantiate an Object class, then set the properties one by one to return a JSON-style object such as this:

{
"id": "123",
"name": "John Doe",
"age": 32,
"gender": "male"
}

The JSI code to create this object in a returnPerson(id: string) function is as follows:

void exampleJsiLibrary(Runtime &runtime) {

auto returnObject = Function::createFromHostFunction(
runtime,
PropNameID::forAscii(runtime, "returnPerson"),
// expects 1 parameter
1,
[](
Runtime &runtime,
const Value &thisValue,
const Value *arguments,
size_t count
) -> Value {
// accepts a `personId: string` argument as a std::string
string id = arguments[0].getString(runtime).utf8(runtime);
// build an object
Object personObject(runtime);
// these are a facebook::jsi::string, not a std::string
String name = String::createFromUtf8(runtime, "John Doe");
String age = 32;
String gender = String::createFromUtf8(runtime, "male");
// set the object properties
personObject.setProperty(runtime, "id", id);
personObject.setProperty(runtime, "name", name);
personObject.setProperty(runtime, "age", age);
personObject.setProperty(runtime, "gender", gender);
// return the object
return Value(runtime, obj);
}
);
runtime.global().setProperty(runtime, "returnObject", move(returnObject));

}

Explanation

  • arguments[0].getString(runtime).utf8(runtime) casts the first argument as a utf-formatted standard C++ string (std::string).
  • Object personObject(runtime) creates an empty JSON-style Object to be returned to TypeScript
  • String name = String::createFromUtf8(runtime, "John Doe) creates an utf8-formatted string using the facebook:jsi::string type, to be compatible with TypeScript later on inside the personObject.
  • personObject.setProperty(runtime, "property", value) Sets a key/value pair in the object, similar to how one might write personObject[“property"] = value in TypeScript.

Example 6: Returning Arrays

Sometimes you need to return an array of items. For example in TypeScript:

const exampleFunction = () => {
// expected function declaration
// const returnArrayExample = () => string[];
const items: string[] = returnStringArray();
}

In Javascript Interface, we can do this by instantiating a facebook::jsi::Array object and popululating it using the setValueAtIndex() method:

auto returnStringArray = Function::createFromHostFunction(
runtime,
PropNameID::forAscii(runtime, "returnStringArray"),
// expects 1 parameter
1,
[](
Runtime &runtime,
const Value &thisValue,
const Value *arguments,
size_t count
) -> Value {
// let's say we have an existing vector of strings
std::vector<std::string> fruit = {"apple", "banana", "kiwi", "cherry"};

// Create a new JSI Array with the size of the std::vector
facebook::jsi::Array jsiFruitArray(runtime, fruit.size());

// Populate the JSI Array with strings from the std::vector
for (size_t i = 0; i < fruit.size(); ++i) {
// Convert each std::string to a JSI String
jsiFruitArray.setValueAtIndex(
runtime,
i,
String::createFromUtf8(runtime, fruit[i])
);
}
return Value(runtime, jsiFruitArray);
}
);

Of course we can populate the Array with objects of any type, including numbers, null, undefined, or arbitrary objects.

// number
jsiFruitArray.setValueAtIndex(
runtime,
i,
Value(32)
);

// null
jsiFruitArray.setValueAtIndex(
runtime,
i,
Value()
);

// undefined
jsiFruitArray.setValueAtIndex(
runtime,
i,
Value::undefined()
);

Example 7: Simple Types as Function Parameters

Function parameters are a source of potential complexity and errors when executing JSI functions from TypeScript because of both the difference in native types between TypeScript and C++, but also because TypeScript types are actually objects which can be null or undefined , or may even be complex object types, such as the Person described above.

Take for example this TypeScript function, which accepts any number of types. Making a JSI function compatible with a call like this can be complex.

const exampleFunction = (parameter: string | number | null | undefined): void => {
};

Even checking that a function conforms to a strict type in JSI is challenging, where TypeScript might let a type slide by.

const exampleFunction = (parameter: string): void => {
return;
}

exampleFunction(123);

Let’s see how to retrieve and test these in JSI.

Determine the Type of a Parameter

Sometimes you need to know the type of a function parameter. Maybe you need to treat it differently or throw an error if the wrong type is passed for example.

Here’s how you do that in JSI:

auto exampleFunction = Function::createFromHostFunction(... { 
// get the argument as a reference to a facebook::jsi::Value object
Value& argument = arguments[0];
// true if the argument is a `string`
bool isString = argument.isString();
// true if the argument is a `number`
bool isNumber = argument.isNumber();
// true if the argument is a `bool`
bool isBool = argument.isBool();
// true if the argument is a `null`
bool isNull = argument.isNull();
// true if the argument is a `undefined`
bool isUndefined = argument.isUndefined();
// true if the argument is an `object`
bool isObject = argument.isObject();
// TypeScript arrays are `object` types, but can be tested separately
bool isArray = false;
if (isObject) {
isArray = argument.getObject(runtime).isArray(runtime);
}
});

Retrieve the Parameter as the Expected Type

Once you’ve safely type-checked a function parameter, you can retrieve the data without creating any runtime exceptions that will crash your program.

auto returnObject = Function::createFromHostFunction(... { 
// get the argument as a reference to a facebook::jsi::Value object
Value& argument = arguments[0];
// if targument.isString() == true, get a utf8 std::string:
string stringValue = argument.getString(runtime).utf8(runtime);
// if argument.isNumber() == true, get a double
double doubleValue = argument.getNumber();
// if argument.isBool() == true, get a bool
bool boolValue = argument.getBool();
// null and undefined types don't return a value
});

Example: optional string parameter

Putting these two concepts together, we can test if an optional string parameter was passed into the JSI function, like this:

// expected function definition:
// const mixedParameter = (string?) => void;
mixedParameter("example");
mixedParameter(undefined);

Such JSI function needs to test if the parameter is undefined or if it is a string or something else.

auto mixedParameter = Function::createFromHostFunction(... { 
// get the argument as a reference to a facebook::jsi::Value object
Value& argument = arguments[0];
if argument.isString()) {
string stringValue = argument.getString(runtime).utf8(runtime);
cout << "parameter was a string: " << stringValue << endl;
} else if (argument.isUndefined()) {
cout << "parameter was undefined" << endl;
} else {
// throw an error
}
});

Example 8: Complex Types as Function Parameters

TypeScript can pass complex objects, which is a challenge to process using JSI because of not only the complexity from the previous example, but also because the object must be destructured, and because the parameter may accept more than one object type.

Take for example a function which accepts a Person object as a parameter:

const doSomethingWithPerson = () => {
// describe a person object
const person: Person = {
id: "123",
name: "John Doe",
age: 32,
gender: "male"
};
// expected function definition:
// const personParameter = (person: Person) => void;
personParameter(person);
}

Let’s see how to destructure a Person object in JSI. Note that for simplicity, this example doesn’t demonstrate type checking, an essential part of a good JSI function:

auto personParameter = Function::createFromHostFunction(... { 
// get the argument as a reference to a facebook::jsi::Value object
Value& argument = arguments[0];
// get the Object data
Object person = argument.getObject(runtime);

// get the person.id as a string
string id = obj.getProperty(runtime, "id").getString(runtime).utf8(runtime);
// get the person.name as as tring
string name= obj.getProperty(runtime, "name").getString(runtime).utf8(runtime);
// get the person.age as a double and type-cast as an int
int age = static_cast<int>(obj.getProperty(runtime, "age").getNumber());
// get the gender as a string
string gender = obj.getProperty(runtime, "gender").getString(runtime).utf8(runtime);

cout << "Person " << name << " is a " << gender << ", " << age << " years old" << endl;
});

Example 9: Array Parameters

Arrays are funny in TypeScript. They report as object types and must be

Let’s say we have a TypeScript function that passes a string array:

const doSomething = () => {
const fruit = ["apple", "banana", "kiwi", "cherry"];
// expected function definition:
// const processFruitArray = (fruit: string[]) => void;
processFruitArray(fruit);
}

The JSI implementation for such a function would look like this:

auto processFruit = Function::createFromHostFunction(... {
// initialize a string array
vector<string> stringArray;

// Check if the argument is an object and if it's an array
if (!argument.isObject() || argument.getObject(runtime).isArray(runtime)) {
// not an array. throw an error
}

// Get the JSI Array from the Value
Array array = argument.getObject(runtime).getArray(runtime);

// Get the length of the array
size_t length = array.size(runtime);

// Iterate over the array elements
for (size_t i = 0; i < length; ++i) {
// Get the i-th element in the array
Value element = array.getValueAtIndex(runtime, i);

// Check if the element is a string
if (!element.isString()) {
// not a string, throw error
}

// Convert the JSI String to a std::string and add it to the vector
stringArray.push_back(element.getString(runtime).utf8(runtime));
}
// do something with the string array here
});

Example 10: Throwing Errors

In TypeScript, many times it is useful to throw an error when something goes wrong.

One useful example with regards to JSI is throwing an error when the wrong data type is sent as a function parameter.

In TypeScript, the expected interaction for this style of error handling is to use a try/catch block, such as this:

try {
errorThrowsException();
catch (e: any) {
console.error(e.getMessage();
}

Writing a function that throws an error in JSI is as simple as throwing a JSIError object or by executing the runtime.throwException() method.

Throwing a new Exception in JSI

Here is how you throw a new Exception in JSI:

auto processFruit = Function::errorThrowsException(... {
// method 1:
throw JSError(
runtime,
String::createFromUtf8("Explanation of error message")
);
// method 2:
runtime.throwException(JSError(
runtime,
String::createFromUtf8("Explanation of error message"))
);
});

Throwing a caught Exception in JSI

Sometimes you are writing C++ code that catches an exception, which you want to pass back to React Native. This can be done by converting native errors to JSError objects.

auto processFruit = Function::errorThrowsException(... {
try {
executeNativeMethodThatFails();
} catch (const std::exception& exception) {
runtime.throwException(JSError(runtime, exception.what()));
}
});

Example 11: Asynchronous Functions

Asynchronous Functions, or Promises in Typescript are a useful way of waiting for asynchrous code to execute before moving to the next step of an algorithm.

In TypeScript, it is common to wait for such methods in this way:

const printHelloWorld = async () => {
// expected function definition:
// const asyncHelloWorld = async () => string;
const response = await asyncHelloWorld();
console.log(response); // output response, "Hello World"
}

In JSI, we need to create a separate Promise function and pass the asyncHelloWorld parameters in to run.

auto asyncHelloWorld = Function::createFromHostFunction(..., {
auto promiseConstructor = runtime.global().getPropertyAsFunction(
runtime,
"Promise"
);
auto promise = promiseConstructor.callAsConstructor(runtime, Function::createFromHostFunction(
runtime,
PropNameID::forAscii(runtime, "executor"),
2, // executor function takes two arguments: resolve and reject
[](
Runtime &runtime,
const Value &thisVal,
const Value *args,
size_t count
) -> Value {
// actual function logic here
}
);
}

The Promise object then

  1. Retrieves the resolve and reject methods from its own arguments,
  2. Perform its logic, resolving or rejecting the promise,
  3. Return undefined to the original function
auto promise = promiseConstructor.callAsConstructor(... {
auto resolve = args[0].getObject(runtime).getFunction(runtime);
auto reject = args[1].getObject(runtime).getFunction(runtime);

// option 1: resolve the promise
resolve.call(runtime, String::createFromAscii(runtime, result));

// option 2: reject the promise
reject.call(runtime, String::createFromAscii(
runtime,
JSIError(runtime, "description of error")
);

// return `undefined` to the original function
return Value::undefined();
});

This syntax makes sense once you remember that one way to describe a Promise in TypeScript is like this:

const asyncHelloWorld = async (): Promise<string> => {
// return the promise
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve("Hello World"); // resolves to string
}, 10);
// no return value inside the promise
});
}

JSI Example:

To see a similar 10 ms Promise resolution in JSI/C++, we must use threads. Combining what we’ve seen already, we get the following:


auto asyncHelloWorld = Function::createFromHostFunction(
runtime,
PropNameID::forAscii(runtime, "asyncHelloWorld"),
0, // number of arguments
[](
Runtime &runtime,
const Value &thisValue,
const Value *arguments, size_t count
) -> Value {
// Create a new promise within the JavaScript environment
auto promiseConstructor = runtime.global().getPropertyAsFunction(runtime, "Promise");
auto promise = promiseConstructor.callAsConstructor(runtime, Function::createFromHostFunction(
runtime,
PropNameID::forAscii(runtime, "executor"),
2, // executor function takes two arguments: resolve and reject
[](Runtime &runtime, const Value &thisVal, const Value *args, size_t count) -> Value {
// args[0] is the resolve function, args[1] is the reject function
auto resolve = args[0].getObject(runtime).getFunction(runtime);
auto reject = args[1].getObject(runtime).getFunction(runtime);

// Example asynchronous operation
std::thread([resolve, reject, &runtime]() {
try {
// Simulate async work with sleep for 10 ms
std::this_thread::sleep_for(std::chrono::seconds(10));
// Resolve the promise with a value
resolve.call(runtime, String::createFromUtf8(runtime, "Hello World"));
} catch (const std::exception& e) {
// Reject the promise if there's an error
reject.call(runtime, String::createFromUtf8(runtime, e.what()));
}
}).detach();

return Value::undefined(); // Executor doesn't directly return anything
}
)
);

return promise; // Return the promise to JavaScript
});

Example 12: Arrow Functions as Parameters

In TypeScript it is common to pass an “arrow function” or callback function as a function parameter, for example:

const exampleWithCallback = (callback: () => void): void => {
callback();
}

Later, you can call the function like this:

exampleWithCallback(() => {
console.log("exampleWithCallback callback executed");
});

It is possible to process these types of functions as parameters in JSI

auto exampleWithCallback = facebook::jsi::Function::createFromHostFunction(
runtime,
facebook::jsi::PropNameID::forAscii(runtime, "exampleWithCallback"),
1, // The function expects 1 argument
[](
facebook::jsi::Runtime &runtime,
const facebook::jsi::Value &thisValue,
const facebook::jsi::Value *arguments,
size_t count
) -> facebook::jsi::Value {
// Check if the first argument is a function
if (count != 1 ||
!arguments[0].isObject() ||
!arguments[0].getObject(runtime).isFunction()
) {
throw facebook::jsi::JSError(
runtime,
"exampleFunction expects a function as the only argument"
);
}

// Get the callback function from the arguments
auto callback = arguments[0].getObject(runtime).getFunction(runtime);

// Call the callback function with no arguments
callback.call(runtime);

// Return undefined since this function doesn't return anything
return facebook::jsi::Value::undefined();
});

Example 13: Arrow Functions with Parameters

We can modify this a little to let the callback method accept parameters and return a value, similar to this declaration in TypeScript:

const exampleWithCallback = (callback: (param: string) => void): void => {
callback();
}

Such a method can be called as follows:

exampleWithCallback((value: string) => {
console.log("parameter:", value);
});

We can implement such a JSI method as follows:

auto exampleFunction = facebook::jsi::Function::createFromHostFunction(...) -> facebook::jsi::Value {
// Check if the first argument is a function, then
// Get the callback function from the arguments
auto callback = arguments[0].getObject(runtime).getFunction(runtime);

// Prepare the string parameter for the callback function
auto param = facebook::jsi::String::createFromUtf8(runtime, "123");

// Call the callback function with the string parameter
auto result = callback.callWithThis(runtime, thisValue, param);

// Check if the result is a string
if (!result.isString()) {
throw facebook::jsi::JSError(runtime, "The callback function must return a string");
}

// Convert the JSI String result to a std::string
std::string resultStr = result.getString(runtime).utf8(runtime);

// Return undefined since this function doesn't return anything to JS
return facebook::jsi::Value::undefined();
});

Integrating with React Native

For each step, after creating your C++ functions, you need to compile them into your React Native project and call them from JavaScript as shown in the original tutorial. The progression from simple to complex functions not only helps in understanding how JSI works but also demonstrates the power of integrating C++ code with React Native, allowing for more performant and flexible apps.

Each code snippet provided here serves as a foundational piece, guiding you through the process of creating, registering, and invoking native functions from JavaScript. By understanding these examples, you’ll be well-equipped to explore more complex JSI integrations and leverage the full potential of native code in your React Native applications.

--

--

Adonis Gaitatzis

Is a technology and branding nerd who can synthesize any business need into a technology solution. Fun projects have included brain imaging tech & mesh routers.