Development Workflow
Compiled releases: supported subset and known differences
What you trade for a compiled release, what the build refuses, and where a compiled release still behaves differently from the JavaScript release.
A compiled release runs your TypeScript as Swift and Kotlin, against a runtime that reproduces JavaScript's semantics. This page lists where that stops: what the build refuses, and where a compiled release still behaves differently from the JavaScript release of the same app. Each entry names the platform it affects.
Before you choose --compiled
What you gain:
- No JavaScript engine in the app. Your TypeScript, your framework and
@nativescript/coreship as Swift and Kotlin. - Smaller apps that use less memory. Recipes (Vue):
.ipa2.35 MB instead of 14 MB, 18.9 MB of memory after launch instead of 47.3 MB. openjs-app (Angular):.ipa17.3 MB instead of 27.8 MB, 40.0 MB installed instead of 79.9 MB. On Google Play, about half the download (measurements). - Not a faster launch. Recipes reaches its first frame in 191 ms compiled and 190 ms on the JavaScript runtime.
What you give up:
- Some JavaScript semantics. Most are reproduced; the differences below are not, and they show only when the app runs.
- Code made or loaded at run time.
eval,new Function,Proxy,Reflectand a computedimport()are refused. - Over-the-air updates. There is no JavaScript bundle to replace. Updates go through the stores.
- Build time. The first iOS build compiles NativeScriptKit, about three minutes for a blank app. A rebuild after a small change takes about a minute on iOS, and 45 seconds on Android with R8.
- Plugins built from their own source. The first build fetches each plugin's TypeScript source with git, and the source must match the published JavaScript. Some native packaging is not carried yet (see What stops a build).
- JavaScript debugging.
ns debugand Chrome DevTools stay with development builds. A compiled release is debugged as a native app in Xcode or Android Studio, anderror.stacklists native frames. - Care with R8 on Android. A member reached by a name computed at run time can be renamed or removed by minification (Android release builds).
fetchandXMLHttpRequeston Android. They are available on iOS only.
You develop on the JavaScript runtime and ship compiled code, so a difference shows only in the release. Check every compiled release against its JavaScript release before you ship it (Checking a compiled release).
What the build refuses
Before anything is compiled, the compiler checks the app (and the plugin code the app reaches) and stops on each of these with the file, the line and what to write instead. All of them are reported at once.
| Construct | Why | Instead |
|---|---|---|
new Proxy(…) | No traps without a JavaScript engine | A class with explicit getters and setters, or a Map for dynamic keys |
Reflect.* | Not provided by the runtime | The operation itself: obj[key], key in obj, Object.keys(obj), new Cls(...args) |
eval(…), new Function(…) | There is no engine to evaluate code | A lookup of known functions; or keep this app on the JavaScript release |
FinalizationRegistry | Finalizers do not run | Release the resource explicitly (dispose(), unloaded, disposeNativeView) |
SharedArrayBuffer, Atomics | No shared memory between workers | An ArrayBuffer, with copies posted between workers |
with (obj) { … } | Name the object explicitly | |
import(expr), require(expr) with a computed specifier | Every module is linked when the app is built | Import each module by a literal path, and choose among those at run time |
arguments.callee, __proto__ | A named function; Object.getPrototypeOf | |
A bundler constant nothing defines (declare const __API_URL__, process.env.X) | It would read as undefined at run time | Define it in the bundler config: Vite's define and webpack's DefinePlugin values are read and compiled in |
The build also stops, before compiling, when:
- The app's
@nativescript/coreis not the core the compiler's kit was generated from. The kit is core's own code, compiled; the native widgets it drives come from your installed core, so the two must be the same release. Install the core version the message names, or the@nativescript/compilerreleased with your core.release: { allowCoreMismatch: true }builds anyway, with a warning. - A framework package is outside the versions its support is tested with (table below).
release: { allowUntestedFrameworks: true }builds anyway, with a warning. - The toolchain is missing or too old: Xcode 26 or newer for iOS; JDK 17 or newer and the Android SDK for Android.
Some library members are not provided yet, and the compiler stops where it meets them, with the file and line: Object.create, String.prototype.normalize, Intl.Collator, Intl.PluralRules, Intl.RelativeTimeFormat, a class expression (const Mixin = (B) => class extends B {}), an array literal with holes ([1, , 3]), and on iOS Number.prototype.toExponential. Intl.NumberFormat, Intl.DateTimeFormat and toLocaleString are provided. A few valid constructs still fail later, in the Swift or Kotlin compile, at your TypeScript line, such as reading a function's length or name. Other unsupported language and framework constructs are listed in What stops a build.
Tested framework versions
| Framework | Packages and versions |
|---|---|
| Vue | nativescript-vue 3.1 and later 3.x |
| Angular | @nativescript/angular and @angular/core 22.x |
| React | react-nativescript 5.x with react 18.2 or later 18.x |
| Solid | @nativescript-community/solid-js and solid-js 2.0 (from 2.0.0-rc.0) |
| Svelte | svelte-native 1.x with svelte 4.2+, or svelte-native 5 (from 5.0.0-alpha.0) with svelte 5.50+ |
| Octane | @nativescript-community/octane 0.2.4 and later 0.2.x |
Framework features each front end does not support yet stop the build with a message. The main ones:
- Angular: under zone.js, a component that does not set
changeDetection: ChangeDetectionStrategy.Default(Angular 22's default is OnPush); pipes other thanasync; a pipe inside*ngFor/@for; more than one@Componentin a file. Templates are parsed with the compiler's own@angular/compiler22. - React: a component body may hold function declarations, variable declarations and the
return; a bare statement such asuseEffect(() => …, [])on its own line is refused. JSX spread attributes are refused. - Vue:
<style module>,<style src>, a propdefault, and Options API keys other thanname,components,props,emits,data,computed,methods,watchandsetup.
Known differences at run time
These compile and run, and behave differently from the JavaScript release. The most likely to affect an app come first.
null and undefined are one value in a typed optional (iOS and Android)
Where a type admits both (name?: string | null), === null and === undefined do not tell them apart: depending on where the value is held, an explicit null reads as undefined, or as both, and undefined passed to a parameter x?: string | null can read as null. A destructuring default applies to null. Where the value is held typed (a variable, a return value, a class field, a write such as o.name = null), JSON.stringify leaves the key out instead of writing null. On iOS, a null written in an object literal or read by JSON.parse is kept by JSON.stringify and Object.keys; on Android it is not. On iOS, map.get(key) of a Map<K, V | null> holding null is also undefined.
Instead: test with == null, which is true for both. Where an API needs an explicit null in JSON, write it in the object literal you send (iOS), or build that JSON by hand.
A class's getters, setters and methods are not found by name (iOS)
In an app built with a framework (Angular, Vue, React, Svelte, Solid, Octane), a member a class declares as a getter, a setter or a method is undefined when read through any or a computed key: (order as any).total, order[key], typeof x.render === 'function', 'total' in order. An assignment through any does not run the setter, and the value is lost. Fields are found. Android finds all of them; so does iOS in a plain TypeScript app with XML, where core's bindings read them.
Instead: read members through a typed reference. For duck typing, test a field, or use instanceof.
A value of the wrong type throws a TypeError (iOS and Android)
JavaScript keeps a value of the wrong type and carries on; a compiled release throws a TypeError you can catch. It happens when an untyped value (any, parsed JSON) is assigned where a class, a string, a number or a boolean is declared, and on a downcast to the wrong subclass (shape as Circle holding a Square). An out-of-union value in an exhaustive switch throws on Android and gives the type's zero (NaN, '', false) on iOS.
Parsed JSON typed as an interface reads a missing string field as '' and a missing number as NaN, where JavaScript has undefined. A field holding the wrong JSON type is converted on iOS ("x" in a number field reads NaN) and throws a TypeError on Android.
Instead: validate parsed data before you type it, and mark fields that may be missing optional (age?: number).
Functions in arrays, Sets and Maps are not found again (iOS; Android for methods)
A function read twice is === to itself, so obj.off('tap', handler) and core's removeEventListener work. But on iOS a function stored in an array, a Set or as a Map key is not found by indexOf, includes, has, delete or get: an emitter that keeps its listeners in a list adds the same handler twice and never removes it. On Android this applies to a method read as a value ([this.onTap].indexOf(this.onTap)); arrow functions are found.
Instead: have on() return a subscription object or an id, and remove by that. Keep listeners in a Map keyed by a string or that id.
Pages and views can stay in memory (iOS; WeakMap and WeakRef on both)
iOS frees objects by reference counting. A closure that a page's view holds and that captures the page (button.on('tap', () => this.save()), an Angular or Vue handler) is a cycle, so a page you navigated away from can stay in memory with its views. Core's own references (parent views, CSS state, gesture observers) do not cause this.
A WeakMap value that refers to its own key keeps both alive on iOS; on Android, only for keys other than plain objects and class instances. On Android, a WeakRef's target is held until memory runs low, not until the next collection.
Instead: remove the listeners you add in unloaded or navigatedFrom, or have the closure reach the page through a WeakRef.
Native callbacks from other threads run on the main thread (iOS and Android)
A callback a native API calls on a background thread (an NSURLSession delegate queue, an audio tap, ConnectivityManager) runs on the main thread; on Android, a callback on an object a worker made runs on that worker's thread. One that returns nothing runs later, as a task. One that returns a value runs while the calling thread waits for it, so it deadlocks if the main thread is waiting for that thread.
Instead: don't block the main thread on a native thread that calls back into your code. Do heavy work in a worker or in native code.
A capture group that did not match is not === undefined (iOS and Android)
For /(a)|(b)/.exec('b'), group 1 is not === undefined, and ?? does not replace it. On iOS it reads ''; on Android it prints undefined. The same holds for match(), for a replace callback's parameter declared string, and on iOS for split() with a capture.
Instead: test with !m[1] or m[1] || fallback. Declare a replace callback's group parameters optional ((all, x?: string) => …): those read undefined.
Arrays with holes (iOS and Android)
Writing past the end of an array of objects (list[5] = item on a shorter list) stops the app on iOS. On Android, iterating a number[] that has holes (forEach, map, for…of) throws a TypeError. On iOS, forEach, filter and reduce call their callback for the holes too, and i in array and Object.keys list them.
Instead: push, or create the array at its length with values (new Array(n).fill(0)).
valueOf (iOS and Android)
On Android, an object literal's own valueOf is not called by arithmetic or comparisons: obj - 1 is NaN. On iOS, a Date held as any gives NaN in arithmetic (date - 0) and false in comparisons. Class instances with a valueOf work on both.
Instead: call .valueOf() or .getTime().
Subclass fields are set before the base constructor runs (iOS)
A base constructor that calls a method the subclass overrides sees the subclass's fields already initialized; in JavaScript they are still undefined. The build warns where an app's class does this.
Reading past the end of a string (iOS and Android)
str[i] past the end is undefined, but a call on it does not throw: str[9].toUpperCase() gives '' on iOS and 'UNDEFINED' on Android, where JavaScript throws a TypeError. In a string[] literal, [str[9]] holds '' on iOS and 'undefined' on Android.
Cutting a string inside a surrogate pair (iOS)
Swift strings cannot hold half of a surrogate pair. A cut between the two halves ('a😀b'.slice(0, 2)) gives U+FFFD, so an emoji split and rejoined is corrupted, and JSON.stringify('\ud83d') writes U+FFFD.
Deep mutual recursion stops the app (iOS)
A function that calls itself, or a method that calls the same method, throws a RangeError when the stack runs out. Two functions that call each other stop the app instead.
error.stack lists native frames (iOS and Android)
error.stack lists the native frames the error was made in, not JavaScript frames. On iOS a frame reads symbol (App+0xoffset), which the archive's dSYM resolves to your TypeScript lines. On Android a frame reads class.method(File.kt:line), which ns-native-retrace maps to your TypeScript lines.
Smaller differences
typeof new String('x')is'string', not'object'(iOS and Android).String(Symbol('s'))gives@@s#1(iOS).JSON.stringifywrites a symbol value as a string (iOS) or{}(Android), where JavaScript leaves it out.'ΑΣ'.toLowerCase()ends inσ, not the finalς(iOS).new RegExp('a/b').sourceisa/b, nota\/b(iOS).- A
TypeError'sconstructor.nameisJSTypeError(Android).new Array(-1)and an over-longpadStartthrow anError, not aRangeError(Android). new Date(y, m, d, h)in the hour a daylight-saving change skips lands an hour early, not late (iOS).Object.assign(target, …)with a literalnullamong the sources stops the app (iOS).
Android release builds (R8)
Members reached by name at run time (a method called on an any value, typeof x.getFoo, 'getFoo' in x) are kept from R8's renaming by a scan of the compiled code. A name computed at run time (x[name]() with name built from strings) is not found by the scan; keep such members with a ProGuard rule in App_Resources/Android/app.gradle, and check the minified release on a device.
By default every class and member of the kit is kept whole, which leaves most of the kit's code to R8 untouched. android: { release: { narrowKitKeeps: true } } keeps only what the kit's reflection needs (class names, constructors, the names of the members that remain) and lets R8 remove the rest: on Recipes (Vue) the kit's dex goes from 3.3 to 2.0 MB and the APK from 2.44 to 1.92 MB, with both screens matching the JavaScript release. It is opt-in until it has been verified on larger apps; under it, a kit member reached only through a computed name is removed.
- Previous
- Compiled releases
- Next
- Apple App Store
