Repository navigation
RFC: Stabilize a Perry native value profile for fixed-width scalars, POD records, and native boundaries #6827
Description
The problem
Perry already has two strong halves of a native-value story, but they are not
yet one stable user-facing contract.
On the application side, Perry intentionally accepts ordinary TypeScript:
number, objects, arrays, interfaces, classes, npm modules, and Node-compatible
APIs. That compatibility should remain the default.
On the compiler and native-library side, Perry already models explicit native
representations:
- the native-representation pipeline has
I32,I64,U32,U64,USize,
F32,F64,BufferLen, native handles, POD records, and verified
materialization boundaries; - the native-library manifest accepts typed ABI descriptors and C-layout POD
records; - generated declarations currently expose internal-looking types such as
PerryI32,PerryU64,PerryF32, andPerryPod<T>; NativeArena, typed views,sizeof<T>(),alignof<T>(), and
offsetof<T>()already exist; and- Typed Native Specialization Pipeline: explicit native reps and verifiable native regions #1849 has landed most of the compiler foundation for explicit native
representations and verifier-backed native regions.
The missing piece is a documented source-level contract that an application or
library author can intentionally use.
Today, a developer can see that Perry internally understands exact widths and
POD layouts, but there is no stable answer to questions such as:
- How do I declare that a value is a
u16,i32,u64, orf32, rather
than an ordinary JavaScriptnumber? - Which conversions are checked, lossy, wrapping, or rejected?
- Can an exact-width value remain native across local variables, calls,
arrays, records, WebAssembly, and native-library boundaries? - How do I define one C-layout record in TypeScript and have Perry verify the
same layout against a native-library manifest? - What happens when
i64oru64crosses a JavaScript-compatible boundary
where anumbercannot represent it exactly? - Which parts are stable language/API guarantees and which parts are compiler
optimizations that may safely fall back to boxed JavaScript values?
Without that contract, native packages either duplicate representation
information in a manifest, pass data through generic number/object values, or
drop down to typed arrays and manual offsets. The compiler can optimize some of
those cases, but the author cannot state and rely on an exact native contract.
A concrete example I would like Perry to support is:
import {
type u16,
type pod,
u32,
u64,
f32,
sizeof,
alignof,
offsetof,
} from "perry/native";
type PacketHeader = pod<{
version: u16;
flags: u16;
payloadLength: u32;
sequence: u64;
gain: f32;
}>;
const header: PacketHeader = {
version: 1,
flags: 0,
payloadLength: u32(payload.length),
sequence: u64(sequence),
gain: f32(0.5),
};
console.log(sizeof<PacketHeader>());
console.log(alignof<PacketHeader>());
console.log(offsetof<PacketHeader>("payloadLength"));The goal is not to make ordinary Perry code look like systems code. The goal is
to let TypeScript code become explicit at the small number of boundaries where
width, range, layout, ABI, or measured performance is part of correctness.
Proposed solution
Stabilize an opt-in Perry native-value profile exposed through a normal,
valid TypeScript module such as perry/native.
This should build on the native representation and ABI work Perry already has,
not introduce a separate parser, file extension, or second compiler pipeline.
Ordinary .ts/.tsx code and ordinary number semantics remain unchanged.
1. Public native scalar types
Expose stable public names for:
| Type | Contract |
|---|---|
i8, i16, i32, i64 |
exact-width signed integers |
u8, u16, u32, u64 |
exact-width unsigned integers |
isize, usize |
target pointer-sized signed/unsigned integers |
f32, f64 |
IEEE-754 32-bit/64-bit floating-point values |
byte |
alias for u8 |
number |
unchanged JavaScript-compatible numeric type |
The existing PerryI32, PerryU64, PerryF32, etc. could remain as
compatibility aliases or internal generated names, while perry/native
provides the supported author-facing surface.
Numeric literals may use contextual typing when they are compile-time
representable:
import { type u16, type i32, type f32 } from "perry/native";
const port: u16 = 8080;
const offset: i32 = -120;
const ratio: f32 = 0.5;Conversions from a dynamic or wider value should be explicit and checked:
import { i32, u32, f32 } from "perry/native";
const count = u32(input);
const delta = i32(otherValue);
const ratio = f32(computation);The important part is that the conversion behavior is specified. A checked
conversion should not silently wrap, truncate, or lose precision. Explicit
wrapping/truncating/saturating operations can be separate APIs if Perry wants
to support them.
2. A stable POD/native-record contract
Promote the existing PerryPod<T> concept to a documented public form such as
pod<T>.
For the first stable version, keep POD deliberately narrow and verifier-friendly:
- ordered fields;
- fixed-width scalar fields;
- nested POD records;
- fixed-size byte/numeric storage if and when Perry has a stable spelling for
it; - pointer-free handle IDs where explicitly supported; and
- no managed
string, normalArray<T>, class instance, closure, promise,
map, set, or arbitrary object fields.
sizeof<T>(), alignof<T>(), and offsetof<T>() should be compile-time
intrinsics for valid POD types. Field order, alignment, padding, and target
dependence must be documented.
POD layout should not silently imply endianness. Binary readers/writers should
continue to state byte order explicitly.
3. One representation contract across source, manifests, and codegen
A TypeScript POD declaration and a perry.nativeLibrary descriptor should be
cross-checked rather than allowed to drift independently.
For example, either:
- the manifest references an exported TypeScript POD type; or
- Perry generates a canonical layout descriptor from the TypeScript type and
compares it with the manifest/native declaration during
perry native validate.
A disagreement in width, signedness, field order, offset, size, alignment,
ownership, or return type should be a compile-time error with both sides shown.
This would also provide a common type vocabulary for:
- statically linked
perry.nativeLibrarypackages; - the C-ABI work in runtime: C-ABI FFI layer (bun:ffi shape) — dlopen, typed calls, pinned buffers, toArrayBuffer, JSCallback #6562;
- WebAssembly imports/exports;
- generated bindings; and
- Perry-owned native APIs.
4. Explicit dynamic-boundary materialization
Native values should remain in their exact representation while Perry can prove
that representation, then materialize only at a real dynamic boundary.
The materialization rules should be part of the contract:
i8throughu32,f32, andf64can convert to a JavaScriptnumber
without changing their mathematical value (subject to the normalf32
rounding already having occurred);i64andu64must not silently become an imprecisenumber;- crossing an untyped/dynamic boundary with
i64/u64should either use
bigint, require an explicit checked conversion, or produce a compile-time
diagnostic; and - a POD record should materialize to an ordinary object only through an
explicit or compiler-recorded boundary, preserving Perry's existing
verifier/artifact model.
This is where the public feature should reuse #1849 rather than bypass it:
native representations, guards, fallback paths, and materialization reasons
remain auditable.
5. Preserve Perry's TypeScript compatibility by default
This proposal is opt-in.
const value = 10; // still an ordinary TypeScript numberIt does not reinterpret every integer-looking literal as an integer, does not
change operators for ordinary number, and does not require systems-level
annotations in normal application code.
The module spelling is also intentional: perry/native makes the non-portable
contract visible while keeping the source valid TypeScript and friendly to
existing editors, formatters, and TypeScript tooling.
Alternatives considered
Keep native representations entirely inferred
Perry should continue to infer and specialize ordinary TypeScript. That is a
major strength, and #1849 is the right foundation for it.
Inference alone is not enough when representation is externally observable:
C ABI calls, binary layouts, persistent formats, WebAssembly signatures,
hardware-facing buffers, and 64-bit integer precision need an author-declared
contract. An optimization may safely fall back; an ABI cannot silently do so.
Use typed arrays and DataView for everything
Typed arrays and DataView are good byte-storage APIs and should remain
supported. They become cumbersome for nested records and require authors to
repeat offsets, sizes, signedness, and alignment manually. They also do not
provide a function/manifest ABI contract by themselves.
Put all type information only in package.json
The native-library manifest already carries useful ABI information, but keeping
it only there duplicates types and prevents normal TypeScript code from
expressing the same contract. The source and manifest should be linked and
validated, not maintained as unrelated schemas.
Add new keywords such as struct, unsafe, or a new source extension now
That would make Perry-specific code clearer in some cases, but it would also
require parser, formatter, editor, language-server, and syntax-highlighting
work before the semantic contract is settled.
The existing branded types, POD analysis, native ABI descriptors, and arena
intrinsics show that Perry can establish the contract using valid TypeScript
first. New syntax can be reconsidered later if the library/type form proves
insufficient.
Treat TypeScript annotations as unconditional runtime truth
This would conflict with the safety model described in #1849. Normal TypeScript
annotations should remain erasable hints. Native types become runtime-relevant
only because the author explicitly opts into the perry/native contract, and
the compiler must still verify conversions, layouts, lifetimes, and boundary
transitions.
Scope
- Small — CLI flag, new stdlib function, doc page
- Medium — new widget, new code-gen pass, new perry.toml field
- Large — new platform target, new backend, cross-cutting compiler change
This is large as a complete feature, but it can land in independently useful
slices:
- stabilize names, documentation, conversions, and diagnostics for the native
scalar types Perry already models; - stabilize and document
pod<T>, layout intrinsics, andNativeArena; - validate TypeScript POD/scalar declarations against native-library
manifests; - extend exact-width coverage (
i8/i16/u8/u16/isize) and cross-target
behavior; and - broaden typed function/collection paths only where verifier-backed
correctness and measurable benefit justify them.
I would be happy for maintainers to split this RFC into a tracker plus
implementer-sized issues.
Additional context
This proposal is intended as a public-contract layer over work Perry already
contains:
- Perry's native-representation documentation
- Typed Native Specialization Pipeline tracker (#1849)
- Current native-library manifest ABI
- Current generated
PerryI32/PerryPod<T>/NativeArenadeclarations - C-ABI FFI tracker (#6562)
I do not intend this to duplicate either tracker. #1849 is primarily the
compiler's specialization, proof, fallback, and materialization foundation.
#6562 is runtime dynamic C-ABI loading and callbacks. This RFC asks for the
stable TypeScript-facing type, conversion, layout, and boundary contract that
can sit above both.
Relevant prior art:
- Bun exposes exact-width scalar and pointer descriptors at its FFI boundary:
https://bun.sh/docs/api/ffi - WebAssembly has explicit
i32,i64,f32, andf64value types:
https://webassembly.github.io/spec/core/syntax/types.html - Rust makes C-compatible layout an explicit representation contract rather
than an inference:
https://doc.rust-lang.org/reference/type-layout.html#the-c-representation
The requested Perry-specific outcome is:
Keep ordinary TypeScript ordinary, but provide one stable way to state exact native width, layout, and boundary behavior when those properties are part of correctness.
Activity
rink3y commentedon Jul 24, 2026
One clarification after posting the RFC: I do not think the native profile should stop at scalar aliases and record layouts. The common thread is a coherent native execution model: values with exact representation, resources with real ownership, and temporary access that the compiler can prove safe.
Arithmetic should look like arithmetic
An
i32oru32should use the operators TypeScript developers already know:There should not be a second arithmetic interface made from one method per operator and overflow mode. If the type is exact-width, the compiler already knows which operation it is lowering.
For the initial contract, I suggest:
f32rounds asf32after each operation;f64has normal IEEE-754 double semantics; andnumberexpressions remain unchanged.That makes this predictable:
but rejects an implicit width change:
I would leave wrapping and saturating arithmetic out of the first version rather than commit Perry to one method per operator. If real crypto, codec, or DSP workloads later need another overflow policy, it should be designed for an expression or lexical region while preserving
a + bas the arithmetic spelling.Conversions are where explicit syntax belongs:
Those conversions should reject fractional integer inputs, negative values for unsigned types, out-of-range values,
NaN, infinity, and silent precision loss. Constant failures should be compiler diagnostics.The
i64/u64case also needs a hard rule. A value may stay in a native 64-bit lane, but it must not quietly become an imprecise JavaScriptnumberwhen it crosses into an untyped object, dynamic call, JSON, or a web host. Perry should materialize it asbigint, require an explicit checked conversion, or reject that crossing.Since the native model needs more than numbers
The useful split is:
Managed values remain the default
Normal objects, arrays, strings, classes, and closures keep the TypeScript
model:
Assignment preserves reference identity, closures can capture the values, and Perry's GC owns their lifetime. A borrow checker should not be imposed on ordinary application objects.
Native scalars and POD records are values
Exact scalars copy by value. A standalone
pod<T>should do the same:That behavior must be explicit because it differs from an ordinary object. It is also what makes a native record useful as more than a layout annotation.
An arena-backed
PodView<T>is different: it is a view into owned storage, so writing through it updates that storage. Perry should distinguish a standalone POD value from a borrowed POD view rather than letting optimization decide the observable semantics.Native resources have ownership
Files, sockets, database connections, GPU allocations, external buffers, OS handles, and FFI objects with finalizers are not copy values. They have one owner and can be transferred:
The call should remain ordinary. Perry can infer the move from the callee's interface; requiring
move(buffer)at every transfer would only expose compiler bookkeeping.Owned resources should integrate with TypeScript's existing cleanup syntax:
Cleanup must run on return, throw, and cancellation. If cleanup itself can fail, the resource should expose an explicit
close()orcommit()result instead of hiding the failure in finalization.Borrowing should be explicit at interfaces, inferred locally
Use
ref<T>for shared read-only access andmut<T>for exclusive mutableaccess:
The useful rules are small:
awaitor a thread transfer.Perry should infer short local borrows from use.
ref<T>andmut<T>earn their keep in exported functions, native declarations, stored callbacks, async work, and other escape points. Requiring them on every local variable would turn useful safety into ceremony.For a synchronous FFI call, passing a typed array can create a borrow for the duration of the call. Perry then keeps the backing memory stable by pinning it or using non-moving storage. An asynchronous native operation cannot retain that temporary borrow: it must take ownership, receive a pinned owned buffer, or copy the input. The function's interface should make that choice visible.
The same ownership information should control thread transfer. An owned native resource crosses threads only if its type is declared transferable; a shared borrow crosses only when concurrent reads are safe;
mut<T>remains exclusive. Perry already carries ownership and thread-affinity metadata for native handles, so this should deepen that existing model rather than introduce a parallel one.Raw pointers can come later
The scalar, POD, arena, ownership, and borrow model is useful without exposing general pointer dereference.
When Perry eventually adds
ptr<T>, operations that can violate layout, aliasing, or lifetime rules should require a small, visible unsafe scope. Library authors can use that scope to build safe wrappers; callers should not inherit the unsafe contract. I would keep raw pointers and the exact unsafe syntax as a later RFC so they do not block the safe native layer.So ordinary Perry code remains ordinary TypeScript. These rules become relevant only when a developer opts into a native contract because width, layout, lifetime, or ABI behavior is part of correctness.
proggeramlug commentedon Aug 24, 2026
Closed by #8720, landed on
mainvia #8723 (squash8beca2f29).That PR was landed through a batch wrapper PR rather than merged directly, so GitHub's close keyword never fired at merge time — its
mergedAtis null even though the work is onmain. Verified before closing:changelog.d/8720-native-value-profile.mdis present onmain.