JavaScript Interop
Dart 3.3+ provides dart:js_interop for seamless interaction with JavaScript. This is how dart_node wraps npm packages like Express and React.
The Basics
Importing dart:js_interop
import 'dart:js_interop';
This gives you access to:
- Extension types for JavaScript objects
- Conversion utilities between Dart and JS
- The
externalkeyword for JS bindings
Calling JavaScript Functions
Global Functions
import 'dart:js_interop';
// Declare the external function
('console.log')
external void consoleLog(JSAny? message);
// Use it
void main() {
consoleLog('Hello from Dart!'.toJS);
}
Importing npm Modules
import 'dart:js_interop';
// Require a Node.js module
('require')
external JSObject require(String module);
void main() {
final express = require('express');
// Now you have the express module!
}
Extension Types
Extension types provide zero-cost wrappers around JavaScript objects. They're the foundation of dart_node's typed APIs.
import 'dart:js_interop';
// Define an extension type for a JS object
extension type JSPerson._(JSObject _) implements JSObject {
// Constructor
external factory JSPerson({String name, int age});
// Properties
external String get name;
external set name(String value);
external int get age;
// Methods
external void greet();
}
void main() {
final person = JSPerson(name: 'Alice', age: 30);
print(person.name); // Access JS property
person.greet(); // Call JS method
}
Type Conversions
Dart to JavaScript
// Primitives
final jsString = 'hello'.toJS; // JSString
final jsNumber = 42.toJS; // JSNumber
final jsBool = true.toJS; // JSBoolean
// Lists
final jsList = [1, 2, 3].toJS; // JSArray
// Maps (as plain JS objects)
final jsObject = {'key': 'value'}.jsify(); // JSObject
JavaScript to Dart
// Primitives
final dartString = jsString.toDart; // String
final dartNumber = jsNumber.toDartInt; // int
final dartBool = jsBool.toDart; // bool
// Arrays
final dartList = jsList.toDart; // List
// Objects (as Map)
final dartMap = jsObject.dartify(); // Map<String, dynamic>
Working with Callbacks
JavaScript often uses callbacks. Here's how to handle them:
extension type EventEmitter._(JSObject _) implements JSObject {
external void on(String event, JSFunction callback);
external void emit(String event, JSAny? data);
}
void main() {
final emitter = getEventEmitter();
// Convert a Dart function to JS
emitter.on('data', ((JSAny? data) {
print('Received: ${data?.dartify()}');
}).toJS);
}
Promises and Futures
JavaScript Promises convert to Dart Futures:
extension type FetchAPI._(JSObject _) implements JSObject {
external JSPromise<Response> fetch(String url);
}
Future<void> main() async {
final api = getFetchAPI();
// JSPromise converts to Future automatically
final response = await api.fetch('https://api.example.com/data').toDart;
print(response.status);
}
How dart_node Uses Interop
Here's a simplified example of how dart_node wraps Express:
// Low-level JS binding
('require')
external JSObject _require(String module);
// Extension type for Express app
extension type ExpressApp._(JSObject _) implements JSObject {
external void get(String path, JSFunction handler);
external void post(String path, JSFunction handler);
external void listen(int port, JSFunction? callback);
}
// High-level Dart API
ExpressApp createExpressApp() {
final express = _require('express');
return (express as JSFunction).callAsFunction() as ExpressApp;
}
// Typed request handler
typedef RequestHandler = void Function(Request req, Response res);
// Convert Dart handler to JS
JSFunction wrapHandler(RequestHandler handler) {
return ((JSObject req, JSObject res) {
handler(Request._(req), Response._(res));
}).toJS;
}
// Usage
void main() {
final app = createExpressApp();
app.get('/'.toJS, wrapHandler((req, res) {
res.send('Hello!');
}));
app.listen(3000, null);
}
Best Practices
1. Hide JSObject from Public APIs
// Bad: Exposes raw JS types
class MyService {
JSObject getData() => fetchData();
}
// Good: Returns Dart types
class MyService {
Map<String, dynamic> getData() => fetchData().dartify();
}
2. Use Extension Types for Type Safety
// Bad: Passing around raw JSObject
void processUser(JSObject user) {
// What properties does user have? Who knows!
}
// Good: Typed extension type
void processUser(JSUser user) {
print(user.name); // Compiler knows this exists
}
3. Handle Null Carefully
JavaScript's null and undefined are both valid. Use JSAny?:
extension type Config._(JSObject _) implements JSObject {
external JSAny? get optionalValue;
}
void main() {
final config = getConfig();
// Check for null/undefined
final value = config.optionalValue;
if (value != null) {
print(value.dartify());
}
}
4. Validate at Boundaries
Validate JavaScript data when it enters your Dart code:
class User {
final String name;
final int age;
User({required this.name, required this.age});
factory User.fromJS(JSObject obj) {
final name = (obj['name'] as JSString?)?.toDart;
final age = (obj['age'] as JSNumber?)?.toDartInt;
if (name == null || age == null) {
throw FormatException('Invalid user object');
}
return User(name: name, age: age);
}
}
Common Patterns
Wrapping a Constructor
('Date')
extension type JSDate._(JSObject _) implements JSObject {
external factory JSDate();
external factory JSDate.fromMilliseconds(int ms);
external int getTime();
external String toISOString();
}
Wrapping Static Methods
('JSON')
extension type JSJSON._(JSObject _) implements JSObject {
external static String stringify(JSAny? value);
external static JSAny? parse(String text);
}
Accessing Global Objects
('window')
external JSObject get window;
('document')
external JSObject get document;
('globalThis')
external JSObject get globalThis;
Debugging Tips
- Check the browser console - JS errors show up there
- Use source maps - Debug Dart code directly
- Print JS objects -
consoleLog(jsObject)shows the raw structure - Type assertions - Use
ascarefully; it can hide errors
Further Reading
- Official JS Interop Documentation
- Extension Types
- dart_node_core Source - See real-world interop examples