JS 타입
JS 타입
JS interop에서 핵심 타입들을 사용하는 방법에 대한 안내예요.
출처: JS types
본문
Dart 값과 JS 값은 서로 다른 언어 영역에 속해요. Wasm으로 컴파일할 때는 이 값들이 서로 다른 런타임에서 실행되기도 해요. 그래서 JS 값은 외부(foreign) 타입으로 취급해야 해요. JS 값에 Dart 타입을 제공하기 위해 dart:js_interop은 JS 접두사가 붙은 일련의 타입, 즉 "JS 타입"을 노출해요. 이 타입들은 컴파일 시점에 Dart 값과 JS 값을 구분하는 데 사용돼요.
중요한 점은 이 타입들이 Wasm으로 컴파일하느냐 JS로 컴파일하느냐에 따라 다르게 구현된다는 거예요. 즉 런타임 타입이 달라지므로, is 검사나 as 캐스트를 사용할 수 없어요. 이 JS 값들과 상호 작용하고 검사하려면 external interop 멤버나 변환(conversion)을 사용해야 해요.
타입 계층 구조
JS 타입은 자연스러운 타입 계층 구조를 이루어요:
- 최상위 타입:
JSAny— 모든 non-nullish JS 값 - 기본 타입(프리미티브):
JSNumber,JSBoolean,JSString,JSSymbol,JSBigInt JSObject— 모든 JS 객체JSFunctionJSExportedDartFunction— JS 함수로 변환된 Dart 콜백을 나타내요JSArrayJSPromiseJSDataViewJSTypedArrayJSUint8Array같은 JS typed arrayJSBoxedDartObject— 사용자가 Dart 값을 같은 Dart 런타임 안에서 불투명하게(opaquely) 박싱하고 전달할 수 있게 해 줘요- Dart 3.4부터는
dart:js_interop의ExternalDartReference타입도 사용자가 Dart 값을 불투명하게 전달할 수 있게 해 주지만, 이건 JS 타입이 아니에요. 각 옵션의 장단점에 대해 더 알아보려면 여기를 참고해요.
각 타입의 정의는 dart:js_interop API 문서에서 확인할 수 있어요.
변환 (Conversions)
한 영역의 값을 다른 영역에서 사용하려면 보통 그 값을 상대 영역의 대응 타입으로 변환하고 싶을 거예요. 예를 들어, Dart의 List<JSString>을 JS 문자열 배열, 즉 JS 타입 JSArray<JSString>으로 표현되는 배열로 변환해서 그 배열을 JS interop API에 전달하고 싶을 수 있어요.
Dart는 여러 Dart 타입과 JS 타입에 다양한 변환 멤버를 제공해서 값들을 서로의 영역으로 변환해 줘요.
Dart에서 JS로 값을 변환하는 멤버는 보통 toJS로 시작해요:
String str = 'hello world';
JSString jsStr = str.toJS;
JS에서 Dart로 값을 변환하는 멤버는 보통 toDart로 시작해요:
JSNumber jsNum = ...;
int integer = jsNum.toDartInt;
모든 JS 타입에 변환이 있는 건 아니고, 모든 Dart 타입에 변환이 있는 것도 아니에요. 일반적으로 변환표는 다음과 같아요:
dart:js_interop 타입 |
Dart 타입 |
|---|---|
JSNumber, JSBoolean, JSString |
num, int, double, bool, String |
JSExportedDartFunction |
Function |
JSArray<T extends JSAny?> |
List<T extends JSAny?> |
JSPromise<T extends JSAny?> |
Future<T extends JSAny?> |
JSUint8Array 같은 typed array |
dart:typed_data의 typed list |
JSBoxedDartObject |
불투명한 Dart 값 |
ExternalDartReference |
불투명한 Dart 값 |
external 선언과 Function.toJS에 대한 요구사항
타입 안전성과 일관성을 보장하기 위해, 컴파일러는 JS로 들어가고 나올 수 있는 타입에 요구사항을 부과해요. 임의의 Dart 값을 JS로 전달하는 것은 허용되지 않아요. 대신 컴파일러는 호환 가능한 interop 타입, ExternalDartReference, 또는 컴파일러가 암시적으로 변환해 줄 기본 타입(프리미티브)을 사용하도록 요구해요. 예를 들어 다음은 허용돼요:
@JS()
external void primitives(String a, int b, double c, num d, bool e);
@JS()
external JSArray jsTypes(JSObject _, JSString __);
extension type InteropType(JSObject _) implements JSObject {}
@JS()
external InteropType get interopType;
@JS()
external void externalDartReference(ExternalDartReference _);
반면 다음은 오류를 돌려줘요:
@JS()
external Function get function;
@JS()
external set list(List _);
이와 같은 요구사항은 Function.toJS를 사용해서 Dart 함수를 JS에서 호출 가능하게 만들 때도 동일하게 적용돼요. 이 콜백으로 들어오고 나가는 값은 호환 가능한 interop 타입이거나 기본 타입이어야 해요.
String 같은 Dart 기본 타입을 사용하면 컴파일러에서 JS 값을 Dart 값으로 변환하는 암시적 변환이 일어나요. 성능이 중요하고 문자열의 내용을 검사할 필요가 없다면, 두 번째 예제처럼 JSString을 사용해서 변환 비용을 피하는 게 합리적일 수 있어요.
호환성, 타입 검사, 캐스트
JS 타입의 런타임 타입은 컴파일러에 따라 달라질 수 있어요. 이는 런타임 타입 검사와 캐스트에 영향을 줘요. 따라서 값이 interop 타입이거나 대상 타입이 interop 타입인 경우의 is 검사는 거의 항상 피하는 게 좋아요:
WebAssembly에서는 모든 JS interop 타입(JSString, JSArray, 사용자 정의 extension type 등)이 단일한 기본 런타임 표현(보통 externref)을 공유해요. Dart의 extension type은 컴파일 시점에 소거(erased)되므로, value is JSString 같은 런타임 검사는 문자열과 다른 JS 값을 구분할 수 없고, 거의 모든 JS 값에 대해 true로 평가되어 조용한 버그로 이어져요.
void f(JSAny a) {
if (a is String) { … }
}
void f(JSAny a) {
if (a is JSObject) { … }
}
또한 Dart 타입과 interop 타입 사이의 캐스트도 피해요:
void f(JSString s) {
s as String;
}
JS 값을 타입 검사하려면 typeofEquals나 instanceOfString처럼 JS 값 자체를 검사하는 interop 멤버를 사용해요:
void f(JSAny a) {
// 여기서 `a`가 JS 함수임이 확인되므로 캐스트는 괜찮아요.
if (a.typeofEquals('function')) {
a as JSFunction;
}
}
Dart 3.4부터는 isA 헬퍼 함수를 사용해 어떤 값이 임의의 interop 타입인지 검사할 수 있어요:
void f(JSAny a) {
if (a.isA<JSString>()) {} // `typeofEquals('string')`
if (a.isA<JSArray>()) {} // `instanceOfString('Array')`
if (a.isA<CustomInteropType>()) {} // `instanceOfString('CustomInteropType')`
}
타입 파라미터에 따라 그 호출을 해당 타입에 맞는 타입 검사로 변환해 줘요.
JS interop 타입으로 잘못된 타입 검사를 하지 않으려면 invalid_runtime_check_with_js_interop_types lint 규칙을 켜 두는 게 좋아요.
null과 undefined
JS에는 null과 undefined라는 두 값이 있어요. 반면 Dart에는 null만 존재해요. JS 값을 더 편리하게 사용할 수 있도록, interop 멤버가 JS null이나 undefined를 반환한다면 컴파일러는 그 값들을 Dart null로 매핑해요. 그래서 다음 예제의 value 같은 멤버는 JS 객체, JS null, 또는 undefined를 반환하는 것으로 해석될 수 있어요:
@JS()
external JSObject? get value;
반환 타입이 nullable로 선언되지 않았다면, 반환된 값이 JS null이나 undefined일 경우 건전성(soundness)을 보장하기 위해 프로그램이 오류를 던져요.
JSBoxedDartObject vs ExternalDartReference
Dart 3.4부터 JSBoxedDartObject와 ExternalDartReference 둘 다 JavaScript를 통해 Dart Object에 대한 불투명한 참조를 전달하는 데 사용될 수 있어요. 다만 JSBoxedDartObject는 불투명한 참조를 JavaScript 객체로 감싸는 반면, ExternalDartReference는 그 참조 자체라서 JS 타입이 아니에요.
JS 타입이 필요하거나 Dart 값이 다른 Dart 런타임으로 전달되지 않도록 하는 추가 검사가 필요하다면 JSBoxedDartObject를 사용해요. 예를 들어 Dart 객체를 JSArray에 넣거나 JSAny를 받는 API에 전달해야 한다면 JSBoxedDartObject를 사용해요. 그 외의 경우에는 더 빠르므로 ExternalDartReference를 사용해요.
ExternalDartReference로/로부터 변환하려면 toExternalReference와 toDartObject를 참고해요.