pyreon

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'
ShapeStatusNotes
Inline object type alias (type X = { a: T; b: U })✅ FullEmitted as struct (Swift) / data class (Kotlin)
Union of string literals (type X = 'a' | 'b')✅ FullEmitted as enum on both targets
Aliased primitive (type Id = number)🟡 SkippedFalls through silently when the alias is a primitive; consumer code uses the underlying type directly
Union with non-string-literal members🟡 SkippedFalls through silently when the alias contains a non-literal union member
Generic type aliases (type Box<T> = …)❌ Phase 3Generic 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
ShapeStatusNotes
let x = literal at module scope✅ FullEmitted as private var x = … (Swift) / var x = … (Kotlin); module-level mutable state
const x = literal at module scope✅ FullEmitted as private let x = … / val x = …
Module-level call expressions (const x = makeThing())🟡 SkippedFalls through silently unless the callee is a recognised hook (signal/computed/etc.)
export const x = …✅ FullSame 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>
}
ShapeStatusNotes
function Comp(props: { … })✅ FullThe canonical shape
const Comp = (props: { … }) => …✅ FullArrow 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)}✅ FullA 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: { … }) { … }✅ FullFunction-expression form, rewritten the same way
export default function Comp(props)✅ FullEmitted as the named component Comp; the host imports a component by name
export default function () { … } / export default () => …⚠️ WarnsAn anonymous component has no name for the host to import. Name it
const Comp: FC<P> = (props) => …⚠️ WarnsTyped on its name, so the parameter's type is invisible natively. Type the parameter instead: (props: P) =>
Async, generator or generic component⚠️ WarnsDeclare a plain function Comp(props: P)
function Comp(props) (untyped)❌ WarningComponent 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)✅ FullRenames 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:

HookShape supportedTarget 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 wrapperPyreonFetch<T> runtime container
useForm({ initialValues })Form-state containerPyreonForm runtime container — initialValues must be a literal object map
useOnline()Network-status signalPyreonNetworkStatus container
usePermissions(['perm.X', 'perm.Y'])RBAC bridgePyreonPermissions container; identifier args silently dropped — only string literals captured
useClipboard()Clipboard containerPyreonClipboard container
useColorScheme()Reactive light/dark@Environment(\.colorScheme) (Swift) / isSystemInDarkTheme() (Kotlin)

Hooks: silent-drop shapes

  • useStorage<unknown>('k', '') (no inferred type) — silently emits unbound T. Always pass the generic.

  • useFetch('/url') without a generic — silently drops the decode type; emit may compile but result.data is Unknown.

  • useForm({ initialValues: x }) where x is not a literal object — silently dropped. Use useForm({ initialValues: { foo: 0 } }).

  • usePermissions([myString]) where myString is an identifier — silently dropped. Pass string literals or migrate to runtime permissions.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 runtime PyreonRouter.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 use const 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.

ShapeStatus
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) }
ShapeStatusNotes
const fn = (a, b) => expr✅ FullExpression-body arrow
const fn = (a, b) => { ... }✅ FullBlock-body arrow
function fn(a, b) { ... }✅ FullFunction declaration (PR landed Round-1)
async function / async () => …🟡 Phase 1Async body must be inside a recognised effect hook
Default parameters (function fn(a = 1))✅ FullEmitted as a native default argument
Rest parameters (function fn(...args))⚠️ WarnsTake 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]))
ShapeStatusNotes
new Map<K, V>() — scalar K, V a scalar, a struct/enum declared in this file, or an array of those✅ FullSwift [K: V]() / Kotlin mutableMapOf<K, V>()
new Map([[k, v], …]) with literal scalar pairs✅ FullNative dictionary literal
new Map(src.map((x) => [key, value]))✅ FullSwift 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⚠️ WarnsA native key/element must be Hashable — key by a scalar id
Inline object (Map<K, { x: number }>) or imported value type⚠️ WarnsDeclare the value shape as a top-level type X = { … } in the file
for (const [k, v] of map)⚠️ WarnsDestructured 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.

ShapeStatusNotes
props.fieldName for an annotated field✅ FullRewritten to platform-native field
props.fieldName for an UN-annotated field❌ Silent dropField 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)✅ FullEach name reads its field; a default becomes props.size ?? 'm'. const only
Destructure with a rest element, a nested pattern or a computed key⚠️ WarnsRead the props off the parameter instead
Spread onto a component (<Child {...props} />, <Child {...obj} />)✅ FullEach known field becomes a named argument
An object literal for an inline-object prop (<Card user={{ name: 'a' }} /> with user?: { name: string })✅ FullConstructs 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>
ShapeStatusNotes
createRouter({ routes: [...] })✅ FullRoutes 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 }✅ Fullpath (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)✅ FullConstructed 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)✅ FullA5 PR #1242 wired into runtime
beforeEnter: () => { … } (block-body)❌ WarningPR #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']✅ FullNamed 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)]✅ FullSame shape as beforeEach
children: [...]✅ FullA4.5 PR #1243 — nested routes with depth-tracked <RouterView />
notFoundComponent: Identifier✅ FullA6 PR #1239 — wildcard-404 fallback
redirect: '/login'🟡 Phase B6Not yet extracted; use router.redirect('/login') at runtime
meta: {...} / name: 'x'❌ Silently ignoredDocumented as Phase B+ — extra fields drop without diagnostic
loader: async ({ params }) => …❌ Silently ignoredCompiler 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>
ShapeStatusNotes
Canonical primitive element (<Stack> etc — all 15)✅ FullPer target via canonical-primitives.ts SWIFT_NAMES / KOTLIN_NAMES
Component element (<UserPage>)✅ FullResolved via local function declarations
Static text child✅ FullLiteral strings, numbers
Expression child {expr}✅ FullMember access, function calls, signal reads
<For each={…} by={…}>✅ FullKeyed iteration emit; by required (else each doesn't typecheck on either target)
<Show when={…}>✅ FullGate emit
{...props} spread❌ Silently ignoredThe spread is dropped; explicit attrs win
Conditional {cond && <X>}✅ FullStandard JSX shape
Ternary {cond ? <A /> : <B />}✅ FullStandard 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").

ShapeWhere in parse.tsWhy silentWorkaround
Module-level destructured const { a, b } = objline ~98Phase 3 — not enumeratedUse const a = obj.a; const b = obj.b;
Type alias with non-literal union memberline ~49Falls through to "complex" pathInline the literal members
Aliased primitive typeline ~190No struct to emitUse the primitive directly
Class declarations(not handled)Not in Phase 0 scopeUse function components
Module-level non-hook const x = call()line ~501Not a recognised patternHoist into a hook the compiler knows
Imports beyond known runtime/JSX modulesn/aPhase 0 doesn't follow importsInline used identifiers
function-declaration inside another functionline ~573Not recognised at body scopeUse const fn = () => …
useStorage without explicit genericline ~520No type info to emitAlways pass useStorage<MyType>(…)
useForm without literal initialValuesline ~700Cannot inspect computed valuesInline the literal initialValues
usePermissions with non-string-literal argline ~676Identifier value invisible to parserPass string literals
Route's loader: async (ctx) => … fieldline ~872Loader auto-emit deferred to Phase B6Use runtime router.setLoaderData() + the A3 diagnostic for useLoaderData<T>()
Route's meta, nameline ~862Not in v1 scopeTrack app-side; use route path as key
Inline-arrow guards in beforeEach/afterEachline ~597Identifier-array parser onlyHoist to named function, then pass identifier
Block-body beforeEnter arrowline ~862A5 wires expression-body only; block-body warns (PR #1136)Use expression body: beforeEnter: () => isAuthed()
useClipboard destructureline ~459Warns (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.

  1. Untyped props parameter (PR #1136) — function X(props) { ... props.title ... } with no annotation.

  2. useClipboard destructure form (PR #1136) — const { copy } = useClipboard().

  3. Block-body per-route beforeEnter (PR #1136) — beforeEnter: () => { ... } (only expression-body arrows are extracted).

  4. Hook inside render callback (PR #1136) — <For>{(item) => { const x = signal(0); ... }}.

  5. Round-1: missing required props (PR #1094) — Icon/Image/Link without name/src/to.

  6. Round-2: silent-drop shapes (PR #1099) — Press without onPress, Link prefetch=… on native, Stack/Inline/Layer align="<typo>".

  7. 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:rect element 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

PMTC Supported TypeScript Surface