Skip to content

Hosting Kio in TypeScript

Host API stability: evolving

Status policy: Host API stability.

This guide walks through compiling a Kio package to TypeScript and calling into it from a TypeScript host with full type-checking. The authoritative contract is specs/backends/ts.md; this guide is the narrative companion.

Known host-source compatibility caveat. When removed host signatures depend on incompatible epochs of one exact nominal declaration, the .d.ts omits those signatures and their conflicting type closure. Existing host source must delete or rewrite the affected optional properties and any import or annotation that names the superseded generated type. See the TypeScript contract's incompatible-retained-epochs section.

The TypeScript backend is the typed-skin companion of the JavaScript backend: kio build ts writes the JS backend's <ns>.js byte-identical plus a generated <ns>.d.ts declaration file that types the FFI boundary (<ns> is the artifact namespace — the package name unless a namespace target key overrides it). The runtime artifact is the same one a JS host runs (see Hosting Kio in JavaScript); the .d.ts adds the types — the standard way a typed npm package ships type annotations for plain JavaScript.

The build target

The concrete generation baseline is v5_0. Write baseline v5_0 to keep that compatibility contract across compiler upgrades, or baseline latest to follow the compiler's newest baseline. Omission and baseline () also follow the newest baseline until kio fmt pins them to its concrete name. See baseline selection for the shared rules.

A package emits to TypeScript by declaring a ts target in <pkg>.pkg.kio:

package greeter;

build {
  cache "out/.kio-cache/";

  target ts {
    out "out/ts/";
    baseline v5_0
  }
}

bridge {
  greeter;
  greeter/**
}

kio build or kio build ts writes the package surface under the target output directory:

out/ts/greeter.js     # the runtime ES module — byte-identical to target=js
out/ts/greeter.d.ts   # the typed FFI skin

The greeter.js is the JavaScript backend's output verbatim — the body is shared with target=js and is drift-proof. The greeter.d.ts is the only artifact ts adds over js: it exports GreeterHostTypes, GreeterHost<B>, Greeter<B>, GreeterTypes<B>, the structural GreeterTypeLambda / GreeterApply / GreeterBind protocol, and the createGreeter factory. Those names are branded from the package namespace greeter (the default; a namespace target key overrides it, renaming the files and the brand together). The runtime targets ECMAScript 2020 (the JS backend's floor); the .d.ts targets TypeScript 5.0+ and type-checks under --strict.

Generated documentation

The declaration file carries Kio docs as JSDoc on host requirements, package members, and named boundary types. Your editor can display them with the corresponding declaration. References remain written Kio names; at-signs and comment terminators are visibly escaped rather than interpreted as annotations. See boundary documentation.

Loading the package

A TypeScript host brings the package online in three steps — the same steps a JS host follows, with types added.

1. Import the factory

import { createGreeter, type Greeter, type GreeterApply, type GreeterHost, type GreeterHostTypes, type GreeterTypeLambda } from './out/ts/greeter.js';

The single runtime export is createGreeter; every other imported name is a type. TypeScript resolves the sibling greeter.d.ts for greeter.js automatically. Importing the module does nothing observable; the package only comes online when you call the factory.

The artifact stem retains the effective namespace: my_pkg.js exports createMyPkg. The public brand, including the exact KioPkg_... and KioNs_... cases, follows the JS namespace rules.

2. Build the host record

The host record is typed by GreeterHost<B>: one member per host fn the bridged modules declare, nested by module. A path without source _ is formed with / replaced by _, keeping lowercase components; a path containing _ uses KioModule_... after word casing and escaping remaining _ as _u and / as _s. Thus foo/bar and foo_bar use distinct foo_bar and KioModule_fooBar properties. Callable leaves also use word casing (do_work → doWork). A host fn print in module greeter is supplied as the readonly callable property greeter.print:

// greeter.kio
module greeter;

host type String role(str);

host fn print(s: String);
const host: GreeterHost = {
  greeter: {
    print: (s) => { process.stdout.write(s); return null; },
  },
};

TypeScript checks the supplied record at compile time, including generic and rank-N parameter and return relations. A host type needs no runtime property — types remain erased from the JavaScript value. A role(...) annotation admits the corresponding Kio literal syntax; the TypeScript backend represents host types carrying these roles with the primitives listed below (here String → string). Function-valued properties preserve strict parameter variance under --strictFunctionTypes.

Selecting native host types

Every roleless host type is selected once in an interface extending GreeterHostTypes. The outer property applies word casing per module component while keeping /: module api/data_store uses the quoted key "api/dataStore". The inner property is the word-cased declaration name (Native_token → NativeToken). These type-selection keys differ from runtime host-record keys. A nullary declaration selects a native type directly. A positive-arity declaration selects a structural type-lambda witness:

type NativeBox<A> = { readonly value: A };

interface BoxK extends GreeterTypeLambda<readonly [unknown]> {
  readonly type: NativeBox<this["arguments"][0]>;
}

interface AppTypes extends GreeterHostTypes {
  readonly app: {
    readonly Token: URL;
    readonly Box: BoxK;
  };
}

type BoxedText = GreeterApply<BoxK, readonly [string]>;
const boxedText: BoxedText = { value: "hello" };

For a nested module the outer key stays readable and exact, for example readonly "app/model": { ... }; this type-only key is independent of the mangled runtime host-record key. Role types never need selection. Annotating a host as GreeterHost<AppTypes> lets createGreeter(host) infer the same AppTypes for the returned handle. A package with no host functions takes no runtime argument; if it still has roleless types, write createGreeter<AppTypes>() to select them.

TypeScript checks every slot in the generated facade. Missing or partial selections, any, unknown, never, wrong constructor arity, and constructors without a concrete result produce branded, uninhabited diagnostic types instead of silently weakening the boundary.

After a host function is removed, the declaration file keeps its exact old function type as an optional property marked @deprecated whenever its frozen type closure is representable alongside the other declaration epochs. This matters for object literals: the old function's parameters and result remain contextually typed under --strict, while a new host simply leaves it out. If every function under an old module path has been removed, that outer property is optional and deprecated too. A module that still has a live function remains required, with only its removed members optional.

Any host-type selection, carrier, alias, or helper declaration kept solely for that old signature is also deprecated. When retained declaration epochs are compatible, old binding maps may continue to select those types; new maps need not include them. If current source uses the same declaration again, it is live, required where applicable, and not deprecated. Compatibility declarations add no factory requirement, package entry, JavaScript validation, or dispatch path; the generated .js remains the current JavaScript backend output byte for byte.

Two incompatible frozen declarations cannot share one exact generated type path. In that case the declaration file omits every affected retained method and type rather than widening either signature or requiring a current host to select history. An old host deletes or rewrites those optional properties and replaces imports or annotations that name the superseded generated type. See the caveat banner above.

3. Instantiate and invoke

const pkg = createGreeter(host);
pkg.greeter.KioModule_main.main();

createGreeter(host) returns a value typed by Greeter<B>; B is inferred from an annotated host when the package has roleless types. Its runtime behavior is the JavaScript backend's. A root module uses word casing; nested modules use KioModule_<exact-component>, and public newtype handles use KioType_<exact-component>. Exact components word-case first, then escape remaining underscores as _u; outer underscore runs survive word casing. An export main from module greeter/main is therefore reached as pkg.greeter.KioModule_main.main.

A public newtype adds a typed namespace containing only its public constructor and projector. A nonexistential newtype with both operations public uses the visible {TypeName: payload} shape unless its payload has an uncut payload-visible recursive cycle. Member-hidden and existential newtypes, and those uncut cycles, use a declaration-private invariant nominal carrier parameterized by the host selection and visible generic arguments. TypeScript can infer and shuttle that carrier, while host code can only use the public constructor/projector operations. An existential projector exposes its hidden type only inside the exact generic CPS continuation. A newtype with no public members has an empty namespace type.

Instances created from the same imported factory with the same host selection and visible type arguments can exchange nominal values. No new nominal type is created per instance, and private members stay private. Their JavaScript runtime storage belongs to the loaded artifact, not to an individual factory call; separately loaded artifacts do not share that identity. Exchanged callbacks retain their original captures and host bindings. See package isolation.

An opaque or one-member carrier is already atomic at the JS boundary and cuts the recursion walk. An enclosing visible wrapper such as Root : Hidden(Root) therefore remains the host-constructible structural { Root: Hidden<Root> } shape. Its declaration uses a private structural interface only to give the recursive reference a finite name; that interface is not a nominal brand.

Types at the boundary

The .d.ts types the JS shapes values cross in — the runtime is the JS backend's, so the declarations describe exactly the plain-object / primitive shapes the .js produces while preserving Kio's type relations.

  • Atomic role types map to TypeScript primitives: str → string, bool → boolean, the i8…i32 / u8…u32 / float roles → number, and the wide-integer roles i64 / i128 / u64 / u128 → bigint (the .js carries those as BigInt).
  • Products (A & B) are binary objects { _0: shape(A); _1: shape(B) }. Each child keeps its complete shape, including nested products and newtype wrappers.
  • Sums (A | B) are discriminated unions { _0: shape(A) } | { _1: shape(B) }, exactly one property set — TypeScript narrows each branch by which property is present.
  • Newtypes follow the visible/nominal rule above. Generic parameters remain visible in either representation. Existentials and uncut payload-visible recursive cycles stay nominal; recursion through an atomic hidden carrier can leave an enclosing public wrapper structural.
  • Unit is null and bottom is never. First-class functions use GreeterCallable<A, R>: one complete A argument, including null for Unit. Supply an ordinary function with that signature.
  • Ordinary Kio type variables are TypeScript generics. A public pub fn id[T](x: T) -> T is <T>(p0: T) => T, and callbacks, returns, and nested polymorphic values instead use GreeterPoly with one generic .advance<T>() per type parameter. For example, an identity value is invoked as identity.advance<string>()("hello"). Supply an object with a generic advance<T>() method returning the next callable or polymorphic layer at each step.
  • Roleless host types use the selected B["<module>"]["<name>"] entry. Applications of positive-arity host types and higher-kinded binders use GreeterApply; non-saturating prefixes use GreeterBind.

GreeterTypeLambda, GreeterApply, and recursive GreeterBind are structural and have no arity cap. Independently authored witnesses and partial applications work across generated packages. The protocol diagnoses a bare base witness, wrong arity, and any / unknown / never results instead of leaking a universal type. Runtime type arguments remain erased: these exact relations add no JavaScript type arguments. The callable helpers preserve the same value and type-stage boundaries when values cross the host boundary.

GreeterTypes<B> provides a readable declaration-keyed catalogue of selected host types, fixed role types, and reachable public Kio newtype constructors for generic host-side composition.

Catalog and type-selection paths word-case each module component while retaining /, and word-case the type leaf. Newtype wrapper keys use the word-cased type name; binary product/sum keys remain _0 and _1. The nominal type brand's opaque identity is not derived from these display keys.

Keep shared tuple fields and nested Kio values unchanged while a retained value or closure can still use them; create a new tuple to change a field. A host-managed mutable Box may change through declared get and set host functions while the tuple keeps the same box. No fresh snapshot is promised. See shared data stays unchanged.

Keep values consistent with their declared types, including callback results and values transported through generic or opaque containers. TypeScript's static checks do not add runtime checks to the JavaScript package. See host values satisfy their declared types.

Tuple and choice field reads must return the same value without side effects. A getter that returns a fixed value is fine; a changing or counter-incrementing getter is not. This is a behavior rule, not a ban on getter or Proxy syntax.

See specs/backends/ts.md § FFI surface for the full mapping.

Worked example

greeter.pkg.kio:

package greeter;

build {
  cache "out/.kio-cache/";

  target ts {
    out "out/ts/";
    baseline v5_0
  }
}

bridge {
  greeter;
  greeter/**
}

greeter.kio declares the host capability:

module greeter;

host type String role(str);
host fn print(s: String);

greeter/main.kio imports it and exports main:

module greeter/main;

import greeter(String, print);

pub fn main() { print("hello from kio\n"(String)) }

Build:

kio build ts

kio build ts writes out/ts/greeter.js (byte-identical to target=js) and out/ts/greeter.d.ts. The .d.ts declares (shape illustrative):

export interface GreeterHostTypes {}

export type GreeterHost<
  B extends GreeterHostTypes = GreeterHostTypes,
> = {
  readonly greeter: {
    readonly print: (p0: string) => null;
  };
};

export type Greeter<
  B extends GreeterHostTypes = GreeterHostTypes,
> = {
  greeter: {
    KioModule_main: {
      main: () => null;
    };
  };
};

export function createGreeter<B extends GreeterHostTypes = GreeterHostTypes>(
  host: GreeterHost<B>,
): Greeter<B>;

A Node host can run the package using the following host.ts, saved beside greeter.pkg.kio:

import { createGreeter, type GreeterHost } from './out/ts/greeter.js';

const host: GreeterHost = {
  greeter: {
    print: (s) => { process.stdout.write(s); return null; },
  },
};

const pkg = createGreeter(host);
pkg.greeter.KioModule_main.main();
// → writes "hello from kio\n" to stdout

Mark that directory as an ES-module package by adding "type": "module" to package.json, or create this file:

{
  "type": "module"
}

Install TypeScript and Node's type declarations for the host's process usage, then compile the host and run it:

npm install --save-dev typescript @types/node
npx tsc --strict --target ES2020 --module NodeNext \
  --moduleResolution NodeNext --types node host.ts
node host.js

The compiler checks the host against out/ts/greeter.d.ts; it compiles host.ts to host.js. Kio's generated .js runs directly as emitted.

Where this leads