Skip to content

RFC: Stabilize a Perry native value profile for fixed-width scalars, POD records, and native boundaries #6827

Description

@rink3y

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, and PerryPod<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:

  1. How do I declare that a value is a u16, i32, u64, or f32, rather
    than an ordinary JavaScript number?
  2. Which conversions are checked, lossy, wrapping, or rejected?
  3. Can an exact-width value remain native across local variables, calls,
    arrays, records, WebAssembly, and native-library boundaries?
  4. How do I define one C-layout record in TypeScript and have Perry verify the
    same layout against a native-library manifest?
  5. What happens when i64 or u64 crosses a JavaScript-compatible boundary
    where a number cannot represent it exactly?
  6. 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, normal Array<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:

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:

  • i8 through u32, f32, and f64 can convert to a JavaScript number
    without changing their mathematical value (subject to the normal f32
    rounding already having occurred);
  • i64 and u64 must not silently become an imprecise number;
  • crossing an untyped/dynamic boundary with i64/u64 should 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 number

It 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:

  1. stabilize names, documentation, conversions, and diagnostics for the native
    scalar types Perry already models;
  2. stabilize and document pod<T>, layout intrinsics, and NativeArena;
  3. validate TypeScript POD/scalar declarations against native-library
    manifests;
  4. extend exact-width coverage (i8/i16/u8/u16/isize) and cross-target
    behavior; and
  5. 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:

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:

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    rfcProposal that needs design agreement before implementation

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions