PMTC Supported TypeScript Surface
The Pyreon Multi-Target Compiler (PMTC) intercepts JSX + TypeScript source in your .tsx files and emits Swift (SwiftUI) and Kotlin (Compose). It does not compile arbitrary TypeScript — Phase 0 deliberately ships a focused subset that covers the seven canonical patterns the TodoMVC + native-counter examples exercise. This page enumerates exactly what works, what silently drops, what fires a warning, and what's planned.
If you hit a shape this page doesn't list, treat it as undefined behavior — file an issue with the source snippet so the matrix can grow.
The accepted-shape catalogue
✅ Type aliases
type Todo = { id: number; text: string; done: boolean }
type Filter = 'all' | 'active' | 'completed'| Shape | Status | Notes |
|---|---|---|
Inline object type alias (type X = { a: T; b: U }) | ✅ Full | Emitted as struct (Swift) / data class (Kotlin) |
Union of string literals (type X = 'a' | 'b') | ✅ Full | Emitted as enum on both targets |
Aliased primitive (type Id = number) | 🟡 Skipped | Falls through silently when the alias is a primitive; consumer code uses the underlying type directly |
| Union with non-string-literal members | 🟡 Skipped | Falls through silently when the alias contains a non-literal union member |
Generic type aliases (type Box<T> = …) | ❌ Phase 3 | Generic type parameters are explicitly skipped |
Empty object type (type X = {}) | ❌ Skipped + warning | "Struct X: skipped — empty object type." |
| Empty string in union branch | ❌ Skipped + warning | "Enum X: skipped empty-string union branch." |
✅ Module-level bindings
let nextId = 1
const TAX_RATE = 0.08| Shape | Status | Notes |
|---|---|---|
let x = literal at module scope | ✅ Full | Emitted as private var x = … (Swift) / var x = … (Kotlin); module-level mutable state |
const x = literal at module scope | ✅ Full | Emitted as private let x = … / val x = … |
Module-level call expressions (const x = makeThing()) | 🟡 Skipped | Falls through silently unless the callee is a recognised hook (signal/computed/etc.) |
export const x = … | ✅ Full | Same as the unexported form; export keyword tolerated |
✅ Functional components
export function Greeting(props: { title: string; count: number }) {
return <Stack><Text>{props.title}: {props.count}</Text></Stack>
}| Shape | Status | Notes |
|---|---|---|
function Comp(props: { … }) | ✅ Full | The canonical shape |
const Comp = (props: { … }) => … | ✅ Full | Arrow form, concise or block body, exported or not. Rewritten into a function declaration before parsing when it is a component: rendered as <Comp …>, taking a props object, or with a block body |
const Row = (x: number) => <…/> called as {Row(1)} | ✅ Full | A view function, not a component: lowers to @ViewBuilder func Row(_ x: Int) / @Composable fun Row(x: Long). A parameterless const Hi = () => <…/> lowers the same way |
const Comp = function (props: { … }) { … } | ✅ Full | Function-expression form, rewritten the same way |
export default function Comp(props) | ✅ Full | Emitted as the named component Comp; the host imports a component by name |
export default function () { … } / export default () => … | ⚠️ Warns | An anonymous component has no name for the host to import. Name it |
const Comp: FC<P> = (props) => … | ⚠️ Warns | Typed on its name, so the parameter's type is invisible natively. Type the parameter instead: (props: P) => |
| Async, generator or generic component | ⚠️ Warns | Declare a plain function Comp(props: P) |
function Comp(props) (untyped) | ❌ Warning | Component X has an untyped 'props' parameter — type-annotate it (e.g. function X(props: { title: string })) — PR #1136. Without annotation, member access (props.X) silently drops. |
function Comp({ a, b }: { a: string; b: number }) (destructured) | ✅ Full | Renames and defaults too: { title, count: n, size = 'm' } reads props.count for n and props.size ?? 'm' for size |
Component with no return statement | ❌ Warning | "Component X: no return statement found; skipping." |
Component returning a fragment (<>…</>) | 🟡 Phase 1+ | Limited — single root element preferred |
✅ Hooks (component-body declarations)
The compiler recognises these hook identifiers and emits per-target equivalents:
| Hook | Shape supported | Target emit |
|---|---|---|
signal<T>(initial) | const x = signal(0) / signal<string>('') | @State (Swift) / var x by remember { mutableStateOf(…) } (Kotlin) |
signal(props.start) (seeded from a prop) | Local state initialised from a prop, including a chain (signal(n() * 2) where n is prop-seeded) | Swift: @State private var n: Int seeded in the generated init (_n = State(initialValue: start)), since a stored-property initializer cannot read a prop. Kotlin: unchanged. A persisted (useStorage) signal seeded from a prop warns by name |
computed(() => expr) | const c = computed(() => a() + b()) | Computed property (Swift) / derivedStateOf (Kotlin) |
useStorage<T>('key', default) | Persistent signal | @PyreonAppStorage (Swift) / rememberPyreonStorage (Kotlin) |
useFetch<T>('/url') | URLSession/ktor wrapper | PyreonFetch<T> runtime container |
useForm({ initialValues }) | Form-state container | PyreonForm runtime container — initialValues must be a literal object map |
useOnline() | Network-status signal | PyreonNetworkStatus container |
usePermissions(['perm.X', 'perm.Y']) | RBAC bridge | PyreonPermissions container; identifier args silently dropped — only string literals captured |
useClipboard() | Clipboard container | PyreonClipboard container |
useColorScheme() | Reactive light/dark | @Environment(\.colorScheme) (Swift) / isSystemInDarkTheme() (Kotlin) |
Hooks: silent-drop shapes
useStorage<unknown>('k', '')(no inferred type) — silently emits unboundT. Always pass the generic.useFetch('/url')without a generic — silently drops the decode type; emit may compile butresult.dataisUnknown.useForm({ initialValues: x })wherexis not a literal object — silently dropped. UseuseForm({ initialValues: { foo: 0 } }).usePermissions([myString])wheremyStringis an identifier — silently dropped. Pass string literals or migrate to runtimepermissions.set().
Hooks: warning shapes (PR #1136, A3)
useLoaderData<T>()— A3 diagnostic (PR #1235). Emits a warning naming the binding because PMTC has no emit yet; the runtimePyreonRouter.setLoaderData()is the only way to populate this signal today. (Real emit lands as Phase B.6.)const { copy, copied } = useClipboard()— destructure form unsupported; warns to useconst cb = useClipboard()instead.
✅ Custom hooks (same file)
A custom hook — a same-file function useX(…) or const useX = (…) => … that calls other hooks — is inlined into each component that calls it before native compilation. Its state becomes that component's state, which is what a hook means: natively, signal / useQuery / useOidc lower to view state (@State, remember), which only exists inside a component, so a hook has no standalone native form.
function useCounter(start: number) {
const count = signal(start)
const increment = () => count.set(count() + 1)
return { count, increment }
}
export function App() {
const { count, increment } = useCounter(1)
return <Button onPress={increment}>{count()}</Button>
}Each call gets its own state (two useCounter calls are two counters), a hook that calls another same-file hook is inlined through, and a hook nothing calls any more is dropped from the native output. Writing the body directly in the component produces the same Swift and Kotlin, byte for byte.
| Shape | Status |
|---|---|
return local / return useQuery(…) read as const x = useX(…) | ✅ Inlined |
return { a, b } read as const { a, b: renamed } = useX(…) | ✅ Inlined — each name maps to the hook's own local |
return { a, b } read as const x = useX(…) | ⚠️ Warns — destructure it, so each value keeps its own native state |
An argument that is a name, a member read or a literal (useMe(auth), useX(props.id)) | ✅ Substituted |
An argument with a cost (useX(compute())) | ⚠️ Warns — bind it to a const and pass the name |
A top-level if / loop / early return in the hook body | ⚠️ Warns — only declarations and expression statements, ending in one return, are inlined |
| A parameter or local re-declared in a nested function | ⚠️ Warns — the rewrite is lexical, so a shadowed name is refused rather than renamed wrongly |
| Async / generator / generic hook, destructured or default parameters | ⚠️ Warns |
A hook imported from a relative module (import { useMe } from './hooks') | ✅ Inlined when the calling file is compiled from disk (as pyreon-native build does) — its imports are added to the calling file, the types it names are copied, and a hook it calls from another file is inlined too |
…that uses a value its own module declares (a module-level const or helper) | ⚠️ Warns — native compiles one file at a time, so copying it could duplicate state; move it into a package or pass it in |
…re-exported through a barrel (export { useMe } from './me', export * from './hooks'), default-exported, or exported under an alias | ✅ Followed to the module that defines it; its own imports resolve relative to that module |
| …compiled from memory, from a file that does not exist, not exported, or in a re-export cycle | ⚠️ Warns — the call is never emitted unchanged |
A type imported from a relative module (import type { Order } from './types') | ✅ Copied in, with the types it names; a different same-named declaration in the importing file warns |
A function whose name does not start with use is a plain helper and keeps the helper path (a top-level native function).
✅ Function declarations
function deleteAt(index: number) { items.value.splice(index, 1) }| Shape | Status | Notes |
|---|---|---|
const fn = (a, b) => expr | ✅ Full | Expression-body arrow |
const fn = (a, b) => { ... } | ✅ Full | Block-body arrow |
function fn(a, b) { ... } | ✅ Full | Function declaration (PR landed Round-1) |
async function / async () => … | 🟡 Phase 1 | Async body must be inside a recognised effect hook |
Default parameters (function fn(a = 1)) | ✅ Full | Emitted as a native default argument |
Rest parameters (function fn(...args)) | ⚠️ Warns | Take an array parameter instead |
✅ Collections (Map / Set)
type Todo = { id: string; title: string }
const byId = new Map<string, Todo>()
byId.set(t.id, t)
byId.get(id)?.title
const index = new Map(todos().map((t) => [t.id, t]))| Shape | Status | Notes |
|---|---|---|
new Map<K, V>() — scalar K, V a scalar, a struct/enum declared in this file, or an array of those | ✅ Full | Swift [K: V]() / Kotlin mutableMapOf<K, V>() |
new Map([[k, v], …]) with literal scalar pairs | ✅ Full | Native dictionary literal |
new Map(src.map((x) => [key, value])) | ✅ Full | Swift Dictionary(…, uniquingKeysWith: { _, last in last }) / Kotlin associate { … }.toMutableMap() — last value wins for a repeated key, as in JS |
.get / .set / .has / .delete / .clear / .size | ✅ Full | |
new Set<T>() / new Set(arr) with scalar elements | ✅ Full | |
Non-scalar Map key or Set element | ⚠️ Warns | A native key/element must be Hashable — key by a scalar id |
Inline object (Map<K, { x: number }>) or imported value type | ⚠️ Warns | Declare the value shape as a top-level type X = { … } in the file |
for (const [k, v] of map) | ⚠️ Warns | Destructured for-of binding is not lowered |
✅ Reactive prop access
function Foo(props: { title: string; count: number }) {
return <Text>{props.title}: {props.count}</Text>
}The props.X member access is rewritten per target. The annotation type is the source of truth — props.unknown (a field not in the annotation) silently emits an unbound reference. Always type-annotate.
| Shape | Status | Notes |
|---|---|---|
props.fieldName for an annotated field | ✅ Full | Rewritten to platform-native field |
props.fieldName for an UN-annotated field | ❌ Silent drop | Field not in annotation = parser doesn't know about it. No warning at this granularity (only for the parent — see "untyped props parameter" above) |
Destructure (const { title, count: n, size = 'm' } = props) | ✅ Full | Each name reads its field; a default becomes props.size ?? 'm'. const only |
| Destructure with a rest element, a nested pattern or a computed key | ⚠️ Warns | Read the props off the parameter instead |
Spread onto a component (<Child {...props} />, <Child {...obj} />) | ✅ Full | Each known field becomes a named argument |
An object literal for an inline-object prop (<Card user={{ name: 'a' }} /> with user?: { name: string }) | ✅ Full | Constructs the prop's own struct (CardUser(name: "a")), whether the component is declared before or after the call site; an array prop of such objects too |
✅ Routing
const router = createRouter({
routes: [
{ path: '/', component: HomePage },
{ path: '/users/:id', component: UserPage, beforeEnter: () => isAuthed() },
],
beforeEach: [authGuard, logGuard],
afterEach: [analytics],
})
return <RouterProvider router={router}><RouterView /></RouterProvider>| Shape | Status | Notes |
|---|---|---|
createRouter({ routes: [...] }) | ✅ Full | Routes extracted. A route the compiler cannot read costs the router its whole table (it never emits a partial one), and is warned by name |
{ path: '/x', component: Identifier } | ✅ Full | path (and redirect) may be a string literal or a module-level const X = '/x'; anything else is warned by name |
A route component's params prop (props: { params: { id: string } } or a named params: UserParams) | ✅ Full | Constructed from the matched path (UserParams(id: params["id"] ?? "")). Flat string / number / boolean fields only; a nested field passes the raw map, which the native build rejects by name |
beforeEnter: () => expr (expression-body) | ✅ Full | A5 PR #1242 wired into runtime |
beforeEnter: () => { … } (block-body) | ❌ Warning | PR #1136 — "Per-route beforeEnter is a block-body arrow — only expression-body arrows are extracted; this route emits UNGUARDED." |
beforeEach: [guard, (to) => to.path !== '/admin'] | ✅ Full | Named and inline guards both lower; to.path reads the target path and an unused from is dropped. A guard that reads to.params/to.query/from, returns a redirect, is async, or captures a component local is warned by name and dropped. The web router honours the same option |
afterEach: [hook, (to) => track(to.path)] | ✅ Full | Same shape as beforeEach |
children: [...] | ✅ Full | A4.5 PR #1243 — nested routes with depth-tracked <RouterView /> |
notFoundComponent: Identifier | ✅ Full | A6 PR #1239 — wildcard-404 fallback |
redirect: '/login' | 🟡 Phase B6 | Not yet extracted; use router.redirect('/login') at runtime |
meta: {...} / name: 'x' | ❌ Silently ignored | Documented as Phase B+ — extra fields drop without diagnostic |
loader: async ({ params }) => … | ❌ Silently ignored | Compiler skips the field; runtime useLoaderData<T>() fires A3 diagnostic instead. Real emit = Phase B6. |
✅ JSX
<Stack space="md">
<Text>Hello {name}</Text>
<For each={items} by={(i) => i.id}>{(item) => <Text>{item.text}</Text>}</For>
</Stack>| Shape | Status | Notes |
|---|---|---|
Canonical primitive element (<Stack> etc — all 15) | ✅ Full | Per target via canonical-primitives.ts SWIFT_NAMES / KOTLIN_NAMES |
Component element (<UserPage>) | ✅ Full | Resolved via local function declarations |
| Static text child | ✅ Full | Literal strings, numbers |
Expression child {expr} | ✅ Full | Member access, function calls, signal reads |
<For each={…} by={…}> | ✅ Full | Keyed iteration emit; by required (else each doesn't typecheck on either target) |
<Show when={…}> | ✅ Full | Gate emit |
{...props} spread | ❌ Silently ignored | The spread is dropped; explicit attrs win |
Conditional {cond && <X>} | ✅ Full | Standard JSX shape |
Ternary {cond ? <A /> : <B />} | ✅ Full | Standard JSX shape |
Element fragment (<>…</>) | 🟡 Phase 1+ | Wrap in <Stack> for now |
Hook calls inside JSX expressions (<For>{() => { const x = signal(0); …}} ) | ❌ Warning (PR #1136) | "Hook signal(…) declared inside <For> render callback — PMTC only extracts hooks at component-body scope. Lift the declaration to the parent component." |
Comprehensive silent-drop catalogue
These are shapes the parser walks but doesn't (yet) emit anything for. The compiler doesn't fire a diagnostic — code compiles and runs, but the silently-dropped shape contributes nothing to the native output. Track at parse.ts (search "silently" / "drop" / "Phase").
| Shape | Where in parse.ts | Why silent | Workaround |
|---|---|---|---|
Module-level destructured const { a, b } = obj | line ~98 | Phase 3 — not enumerated | Use const a = obj.a; const b = obj.b; |
| Type alias with non-literal union member | line ~49 | Falls through to "complex" path | Inline the literal members |
| Aliased primitive type | line ~190 | No struct to emit | Use the primitive directly |
| Class declarations | (not handled) | Not in Phase 0 scope | Use function components |
Module-level non-hook const x = call() | line ~501 | Not a recognised pattern | Hoist into a hook the compiler knows |
| Imports beyond known runtime/JSX modules | n/a | Phase 0 doesn't follow imports | Inline used identifiers |
function-declaration inside another function | line ~573 | Not recognised at body scope | Use const fn = () => … |
useStorage without explicit generic | line ~520 | No type info to emit | Always pass useStorage<MyType>(…) |
useForm without literal initialValues | line ~700 | Cannot inspect computed values | Inline the literal initialValues |
usePermissions with non-string-literal arg | line ~676 | Identifier value invisible to parser | Pass string literals |
Route's loader: async (ctx) => … field | line ~872 | Loader auto-emit deferred to Phase B6 | Use runtime router.setLoaderData() + the A3 diagnostic for useLoaderData<T>() |
Route's meta, name | line ~862 | Not in v1 scope | Track app-side; use route path as key |
Inline-arrow guards in beforeEach/afterEach | line ~597 | Identifier-array parser only | Hoist to named function, then pass identifier |
Block-body beforeEnter arrow | line ~862 | A5 wires expression-body only; block-body warns (PR #1136) | Use expression body: beforeEnter: () => isAuthed() |
useClipboard destructure | line ~459 | Warns (PR #1136) | Use single-binding shape |
Diagnostic warnings (already fire today)
These warnings ship in parse.ts and surface via result.warnings from @pyreon/native-compiler's transform(). The CLI builder (pyreon-native build) also prints them to stderr.
Untyped props parameter (PR #1136) —
function X(props) { ... props.title ... }with no annotation.useClipboarddestructure form (PR #1136) —const { copy } = useClipboard().Block-body per-route
beforeEnter(PR #1136) —beforeEnter: () => { ... }(only expression-body arrows are extracted).Hook inside render callback (PR #1136) —
<For>{(item) => { const x = signal(0); ... }}.Round-1: missing required props (PR #1094) —
Icon/Image/Linkwithoutname/src/to.Round-2: silent-drop shapes (PR #1099) —
PresswithoutonPress,Link prefetch=…on native,Stack/Inline/Layer align="<typo>".A3:
useLoaderData<T>()(PR #1235) — silent-drop diagnostic naming the binding; runtime emit is Phase B6.
Consuming compiler diagnostics
import { transform } from '@pyreon/native-compiler'
const result = transform(source, { target: 'swift' })
console.log(result.code) // emitted Swift
console.log(result.warnings) // ['Component X has an untyped …', …]The CLI (pyreon-native build) prints [pyreon-native] N warning(s): to stderr automatically. No Vite-plugin or LSP/editor surfacer exists yet — that's a Phase D6 follow-up.
What's NOT supported (and not planned for Phase 0)
The audit's Phase B/C/D roadmap explicitly does NOT cover:
Class components — Pyreon's web side hasn't shipped classes either; not a multi-target concern.
Hooks rules (call-from-render-context-only, etc.) — Pyreon doesn't have React's hook rules; the compiler-level constraint is "hook declarations live at component body scope".
JSX namespacing (e.g.
svg:rectelement prefix syntax) — not in v1.Generic type parameters on user types — explicitly Phase 3 work.
Conditional types (
T extends U ? A : B) — not parsed; treat as opaque.Decorators — not in v1.
Higher-order components (HOC pattern) — partially possible if the HOC is just a function returning a component, but the type-flow gets lost.
If you need any of the above, file an issue with the source pattern; the matrix grows from real-world demand.
Cross-references
packages/native/compiler/src/parse.ts— source of truth for what gets extractedpackages/native/compiler/src/types.ts— DeclIR + ExprIR union shapespackages/native/compiler/src/tests/native-audit-warnings.test.ts— locked diagnostic-warning cataloguedocs/src/content/docs/multiplatform.md— the architectural overview