There are two main ways you can communicate between native and JS.
Choose which one to use based on your use case.
For interaction between your view's TypeScript and native code, the Context object will probably be the best solution for you.
Using the annotations API, the Valdi compiler can generate strongly typed native interfaces/classes which do the conversions in and out between TypeScript and native code.
This is an example of an interaction between both platforms with them:
You can call native logic from TypeScript valdi code:
/**
* @Context
* @ExportModel({
* ios: 'SCYourComponentContext',
* android: 'com.snap.myfeature.YourComponentContext'
* })
*/
interface YourComponentContext {
callMeFromTS?();
}
/**
* @Component
* [...]
*/
class YourComponent extends Component<YourComponentViewModel, YourComponentContext> {
onMyButtonWasTapped() {
// Calls callMeFromTS: on the SCYourComponentContext (if it has been configured)
this.context.callMeFromTS?.();
}
}This is what the generated Obj-C counterpart looks like:
@interface SCYourComponentContext: SCValdiMarshallableObject
@property (copy, nonatomic) SCYourComponentContextOnDoneBlock _Nullable callMeFromTS;
- (instancetype _Nonnull)init;
// ...
@end
//////////
// So you can instantiate SCYourComponentContext and configure it with the callMeFromTS block:
SCYourComponentContext *componentContext = [[SCYourComponentContext alloc] init];
componentContext.callMeFromTS = ^{
// Will be called when this.context.callMeFromTS() is called in TS.
NSLog(@"Hello from Objective-C");
}and Kotlin:
package com.snap.myfeature.YourComponentContext
class SCYourComponentContextImpl {
val onDone: (() -> Unit)?
// ...
}
//////////
// So you can instantiate YourComponentContext and configure it with the callMeFromTS block:
val componentContext = YourComponentContext()
componentContext.callMeFromTS = {
// Will be called when this.context.callMeFromTS() is called in TS.
print("Hello from Kotlin")
}You can also pass callbacks from JS to native:
TypeScript:
interface YourComponentContext {
callMeFromTS?(completion: (arg: string) => void);
}
class YourComponent extends Component<any, YourComponentContext> {
onMyButtonWasTapped() {
this.context.callMeFromTS((arg) => {
console.log('the native code called the completion function with arg:', arg);
});
}
}Objective-C:
componentContext.callMeFromTSWithCompletion = ^(YourComponentContextCallMeFromTSCompletionBlock completion) {
// This will call the TS callback and provide the given value.
completion(@"I got you loud and clear");
}Kotlin:
componentContext.callMeFromTSWithCompletion = { completion ->
// This will call the TS callback and provide the given value.
completion(@"I got you loud and clear");
}This will print 'the native code called the completion function with arg: I got you loud and clear'.
If you need to use TypeScript code outside of a Valdi component, you can use the @ExportFunction annotation:
// @ExportFunction({ios: 'SCMultiplier', android: 'com.valdi.example.Multiplier'})
export function multiply(left: number, right: number): number {
return left * right;
}This will generate an Objective-C/Kotlin file which can call this function, and will handle parameter serialization/deserialization automatically:
#import <ModuleName/SCMultiplier.h>
- (void)viewDidLoad
{
/// inject the id<SCValdiRuntimeProtocol> dependency
[valdiRuntime getJSRuntimeWithBlock:^(id<SCValdiJSRuntime> runtime) {
double result = SCMultiplierMultiply(runtime, 2, 4);
NSLog(@"Result is: %fs", result);
}];
}@Inject lateinit var runtime: IValdiRuntime
fun onCreate() {
runtime.createScopedJSRuntime {
val result = Multiplier.multiply(it, 2, 4)
println("Result is: ${result}")
}
}This API supports the whole range of the Valdi annotations. As such it can be used to pass in/return complex objects:
// @ExportModel({ios: 'SCValdiUser', android: 'com.valdi.example.User'})
interface User {
name: string;
}
// @ExportModel({ios: 'SCValdiSearchEngine', android: 'com.valdi.example.SearchEngine'})
interface SearchEngine {
search(term: string, completion: (results: User[]) => void);
}
// @ExportFunction({ios: 'SCValdiSearchEngineFactory', android: 'com.valdi.example.SearchEngineFactory'})
export function makeSearchEngine(users: User[]): SearchEngine {
// Note: in a near future, you will be able to make the class itself
// implements the interface and return it.
const engine = new ConcreteSearchEngine(users);
return {
search(term: string, completion: (results: User[]) => void) {
const results = engine.performSearch(term);
completion(results);
}
};
}iOS:
- (void)viewDidLoad
{
[UIView.valdiRuntime getJSRuntimeWithBlock:^(id<SCValdiJSRuntime> runtime) {
NSArray<SCValdiUser *> *allUsers = fetchAllUsers();
SCValdiSearchEngine *searchEngine = SCValdiSearchEngineFactoryMakeSearchEngine(runtime, allUsers);
self.searchEngine = searchEngine;
}];
}
// Later on...
- (void)updateUsers
{
[self.searchEngine searchWithTerm:@"Simon" withCompletion:^(NSArray<SCValdiUser *> *results) {
NSLog(@"Found those users: %@", results);
}];
}Android:
@Inject lateinit var runtime: IValdiRuntime
var searchEngine: SearchEngine? = null
fun onCreate() {
runtime.createScopedJSRuntime {
val allUsers = fetchAllUsers()
val searchEngine = SearchEngineFactory.makeSearchEngine(it, allUsers)
this.searchEngine = searchEngine
}
}
// Later on...
fun updateUsers() {
searchEngine?.search("Simon") {
println("Found those users: ${it}")
}
}Note that calling a JS function will automatically asynchronously dispatch to the JS thread if needed. If your function need to return a value synchronously to platform code, make sure you call this function within a createScopedJSRuntime scope. If you use asynchronous completion functions this will never be a problem.
If you want to be able to write a reuseable module using a separate language like Kotlin, Java, Objective-C, C++ or Swift, with an exposed TS API, you can make a polyglot module. See documentation about polyglot modules for more details.
This section documents all TypeScript types that can be marshalled between TypeScript and native code.
| TypeScript Type | iOS (Obj-C) | iOS (Swift) | Android (Kotlin) | C++ | Notes |
|---|---|---|---|---|---|
string |
NSString * |
String |
String |
std::string |
UTF-8 encoded |
number |
double |
Double |
Double |
double |
64-bit floating point |
boolean |
BOOL |
Bool |
Boolean |
bool |
|
void |
void |
Void |
Unit |
void |
For return types |
any |
id |
Any |
Any |
Value |
Dynamic/untyped value |
object |
id |
Any |
Any |
Value |
Untyped object |
| TypeScript Type | Native Equivalent | Notes |
|---|---|---|
T[] |
Array/NSArray/List | Arrays of any supported type |
Promise<T> |
Async operation | Mapped to platform async patterns |
CancelablePromise<T> |
Cancelable async | Extended Promise with cancellation |
Map<string, any> |
Dictionary/Map | Currently only supports string keys and any values |
(arg: T) => R |
Block/Closure/Lambda | Function/callback types |
T | null | undefined |
Optional/nullable | Nullable types |
T? |
Optional/nullable | TypeScript optional syntax |
| TypeScript Type | Purpose | Native Equivalent |
|---|---|---|
Uint8Array |
Binary data | Data (iOS) / ByteArray (Android) |
Long |
64-bit integers | int64_t / Long |
Number |
Explicit double | Same as number but explicit |
Types defined with annotations can be passed between TypeScript and native:
@ExportModelinterfaces/classes → Generated native classes@ExportProxyinterfaces → Native must implement@ExportEnumenums → Generated native enums
Example:
// @ExportModel({ios: 'SCUser', android: 'com.example.User'})
interface User {
name: string;
age: number;
friends?: User[]; // Optional array of User
}Generics are supported on generated types:
// @ExportModel({ios: 'SCContainer', android: 'com.example.Container'})
interface Container<T> {
value: T;
}
// Usage
interface MyContext {
userContainer: Container<User>;
stringContainer: Container<string>;
}Note: Generics only work on types annotated with
@ExportModel. Generic parameters must be resolved to concrete types at the interface boundary.
✅ SUPPORTED: Union with null/undefined
property: string | null;
property: string | undefined;
property?: string; // Equivalent to: string | undefined❌ NOT SUPPORTED: Other union types
property: string | number; // ERROR: Only null/undefined unions supported
property: 'red' | 'blue' | 'green'; // ERROR: Use enum insteadSolution: Use enums for multiple value choices:
// @ExportEnum({ios: 'Color', android: 'com.example.Color'})
enum Color {
Red = 'red',
Blue = 'blue',
Green = 'green'
}
interface Config {
color: Color; // ✅ Works
}// ✅ SUPPORTED: Map with string keys and any values
metadata: Map<string, any>;
// ❌ NOT FULLY SUPPORTED: Typed maps
typedMap: Map<string, User>; // Limited - treated as Map<string, any>
// ❌ NOT SUPPORTED: Non-string keys
numberMap: Map<number, string>; // Not supportedWorkaround: Use objects with @ExportModel for typed dictionaries:
// @ExportModel
interface UserMap {
[key: string]: User; // Use index signature
}Arrays can be nested to any depth:
matrix: number[][]; // ✅ 2D array
cube: number[][][]; // ✅ 3D arrayTypeScript number always maps to native double (64-bit floating point):
interface Data {
count: number; // Native: double (not int!)
}For 64-bit integers:
interface Data {
timestamp: Long; // Use Long type for precise 64-bit integers
}Warning: JavaScript numbers can only precisely represent integers up to 2^53 - 1. For larger integers, use
Longtype or string representation.
Arrays are copied when crossing the native/TypeScript boundary:
interface DataProcessor {
// Array is copied from native to TS
processData(items: string[]): void;
// Array is copied from TS to native
getData(): string[];
}Implications:
- Modifications in one environment don't affect the other
- Large arrays have serialization overhead
- Consider using
Uint8Arrayfor binary data to reduce overhead
Only objects with annotations can be marshalled:
// ✅ Can be passed - has @ExportModel
// @ExportModel
interface User {
name: string;
}
// ❌ Cannot be passed - plain object
interface Config {
data: { key: string, value: number }; // ERROR
}Solution: Use any with @Untyped annotation:
interface Config {
// @Untyped
data: any; // Dynamic object, no type safety
}Or define a proper interface:
// @ExportModel
interface KeyValue {
key: string;
value: number;
}
interface Config {
data: KeyValue; // ✅ Works
}Callbacks are reference-counted:
interface Context {
callback: () => void;
}Memory Management:
- Native holds a strong reference to the callback
- Callback is released when native object is destroyed
- Use
@SingleCallto automatically release after first invocation
interface Context {
// @SingleCall
onComplete: () => void; // Automatically released after first call
}All TypeScript function calls are dispatched to the JavaScript thread:
interface DataFetcher {
// Automatically dispatches to JS thread
fetchData(): Promise<Data>;
}For synchronous calls:
// iOS: Use scoped runtime
[valdiRuntime getJSRuntimeWithBlock:^(id<SCValdiJSRuntime> runtime) {
// Synchronous calls work here
double result = MyFunction(runtime, arg);
}];// Android: Use scoped runtime
runtime.createScopedJSRuntime { jsRuntime ->
// Synchronous calls work here
val result = MyFunction.call(jsRuntime, arg)
}For heavy operations:
interface Processor {
// @WorkerThread
processLargeDataset(data: Uint8Array): Promise<Result>;
}Each parameter is serialized/deserialized when crossing the boundary:
// ❌ BAD: Many small calls
for (let i = 0; i < 1000; i++) {
context.updateProgress(i); // 1000 boundary crossings!
}
// ✅ GOOD: Batch operations
context.updateProgressBatch(progressArray); // 1 boundary crossingMinimize chatty APIs:
// ❌ BAD: Callback for each item
interface ItemProcessor {
processItems(items: Item[], onEachItem: (item: Item) => void): void;
}
// ✅ GOOD: Single completion callback
interface ItemProcessor {
processItems(items: Item[]): Promise<Item[]>;
}- Keep interfaces simple - Use primitive types when possible
- Use
@ExportModelfor complex types - Don't rely onany - Document null behavior - Be explicit about optional properties
- Use
@SingleCallfor one-time callbacks - Prevents memory leaks - Batch operations - Reduce boundary crossings
- Use
Promisefor async operations - More ergonomic than callbacks - Use
Uint8Arrayfor binary data - More efficient than arrays
- Don't use union types (except with null/undefined)
- Don't pass large objects frequently - High serialization cost
- Don't assume synchronous execution - Use scoped runtime if needed
- Don't hold strong references to callbacks indefinitely
- Don't pass plain JavaScript objects - Use
@ExportModelinstead - Don't use
Mapwith non-string keys
Async Operations:
interface DataFetcher {
// ✅ PREFERRED: Promise-based
fetchData(): Promise<Data>;
}Bidirectional Communication:
interface Chat {
sendMessage(text: string): void;
// Native → TS callbacks
onMessageReceived?: (text: string) => void;
onError?: (error: string) => void;
}Resource Management:
interface FileReader {
open(path: string): void;
read(): Uint8Array;
close(): void; // Important: Always provide cleanup
}- Native Annotations - Complete annotation reference
- Native Context - Context patterns and dependency injection
- Polyglot Modules - Writing native modules
- Integration Codelabs - Step-by-step integration guides