Real applications don't fit one backend. Your web UI stays in JavaScript, your hot path compiles to native C, your edge worker ships as WebAssembly. MetaScript splits compilation at the module boundary — one codebase, one import graph, one type system, but each file picks the backend that fits its job.
Start with pure .ms everywhere; split a specific module into .cms
/ .jms / .wms only when that module actually needs different code
per target. Evolution, not big-bang rewrite — same stance as
Multiple backends.
Three mechanisms, pick by how big the per-target diff is:
- Module resolution — import without extension; per-target priority.
- Backend-specific files (
.cms/.jms/.wms) — whole module differs per target. @targetblocks — conditional compilation inline (experimental — parser ships, expansion still landing; prefer file-extension split for now).
1. Module resolution order
When you write import { storage } from "./storage" — no extension —
the compiler tries candidates in a fixed order based on the current
--target:
| Target | Resolution order |
|---|---|
--target=c | .cms → .ms → .ts |
--target=js | .jms → .ms → .ts |
--target=c --os=wasm / --os=emcc | .wms → .cms → .ms → .ts |
The first file found on disk wins. A C build prefers .cms; a JS
build prefers .jms; WASM (which compiles through the C backend)
prefers .wms, then .cms. Whichever target you build, plain .ms
and .ts are the universal fallbacks — so you can start with a
single .ms file and split it later without rewriting importers.
Two things to remember:
- Extension is inferred for
.ms/.ts/.cms/.jms/.wms. Idiomatic imports drop the extension. .himports require the explicit.hextension. C headers are never inferred — they live alongside source files and the compiler doesn't try./fooas./foo.h..jsis not an importable source. Plain JavaScript files are not parsed; useexternto declare their surface instead (see the FFI reference).
import { storage } from "./storage"; // inferred: .cms on C, .jms on JS, etc.
import { parse } from "./parser.ts"; // explicit .ts (still works)
import { sqlite3 } from "sqlite3.h"; // .h must be explicit2. Backend-specific file extensions — the killer path
This is the mechanism you will reach for most often for meaningful per-target differences. Put two files next to each other, same base name, different extensions:
src/storage/
index.cms # C build uses this
index.jms # JS build uses thisImporters don't care:
// Anywhere in your codebase
import { save, load } from "./storage";On --target=c, the C build picks index.cms. On --target=js, the
JS build picks index.jms. On WASM, index.wms if it exists,
otherwise index.cms (WASM falls back to the C file).
| Extension | Used on target | Use it for |
|---|---|---|
.ms | Any | Pure logic that compiles to every target |
.cms | C, WASM | C-only implementations — .h extern calls, syscalls, native I/O |
.jms | JS | JS-only implementations — fetch, DOM, Node built-ins |
.wms | WASM | WASM-only overrides on top of the C backend |
.ts | Any (fallback) | Shared code readable from a TS project |
The module graph is still one graph — both files declare the same exported symbols, and the compiler's type checker verifies both paths satisfy the downstream imports' expectations.
Unify signatures in a shared .ms
Once you've written .cms and .jms implementations, the shared
.ms might look redundant — both real bodies already live somewhere.
It earns its keep: LSP, the type checker, and cross-target tooling
all consult the .ms as the universal signature of what a module
exposes. Without it, hover, go-to-definition, and cross-target type
compatibility all degrade to "depends which target you're building".
The pattern: declare the same signatures in a sibling .ms with
unreachable; bodies. The compiler still picks .cms or .jms at
codegen; the .ms is what tooling reads.
// src/storage/index.ms — canonical signatures for every target
export function save(key: string, value: string): void { unreachable; }
export function load(key: string): string | null { unreachable; }3. @target blocks (experimental)
⚠️ Experimental. Parser ships, expansion is still landing. Use
.cms/.jmsfiles (§2) for production splits today.
Put target-specific code inside @target("c") { ... }. When you
build, the compiler keeps only the block matching the current
--target and drops the rest.
@target("c") {
extern function malloc(size: number): number;
extern function free(ptr: number): void;
}
@target("js") {
function allocate(size: number): number {
return 0;
}
}Blocks can chain with else when the two branches are meant to be
mutually exclusive:
@target("c") {
const cache = "/tmp/app-cache";
} else {
const cache = (globalThis.process?.platform === "win32")
? "C:/tmp/app-cache"
: "/tmp/app-cache";
}Plain else catches every non-c target. When you have three or more
targets and need to name a specific fallback, chain with
else @target("js") { ... }; and a single block can cover multiple
targets via @target("c", "wasm") { ... }.
4. Three-layer architecture
For non-trivial projects, the pattern that scales is:
src/
├── core/ # pure .ms — no I/O, no platform calls
│ ├── domain.ms
│ └── validation.ms
├── http/
│ ├── client.ms # shared interface + logic
│ ├── client.cms # C implementation — sockets via .h
│ └── client.jms # JS implementation — fetch via extern
├── storage/
│ ├── index.cms # C: sqlite via sqlite3.h
│ └── index.jms # JS: localStorage via extern
└── index.ms # entry — imports everything target-neutrallycore/holds pure logic — nofetch, noprocess, no C libraries. Importable from any target.http/,storage/hold target-specific implementations behind a shared interface.index.mspulls the pieces together. It imports./http/client(no extension) and lets the resolver pick the right file per build.
The explicit test: can core/ be read by a .ts tooling script
outside the MetaScript build? If yes, you've kept the boundary clean.
5. Worked example — HTTP client
// src/http/client.cms — C build picks this
extern function curl_get(url: string): string from "curl/curl.h";
export async function get(url: string): Promise<string> {
return curl_get(url);
}// src/http/client.jms — JS build picks this
extern function fetch(url: string): Promise<{ text(): Promise<string> }>;
export async function get(url: string): Promise<string> {
return (await fetch(url)).text();
}Caller: import { get } from "./http/client" — same on every target.
6. Decision table — which mechanism?
| Situation | Mechanism |
|---|---|
| Pure logic, same code everywhere | .ms |
| Whole module differs per target | .cms / .jms / .wms |
| 2–10 line inline difference | @target("c") { ... } block (experimental — consider .cms / .jms instead until stable) |
| Shared with an adjacent TypeScript project | .ts |
| Calling a C library | .cms + .h extern / import |
| Calling Node / browser / Deno globals | .jms + extern declaration |
| WASM-only override on top of C | .wms (falls back to .cms) |
7. Common pitfalls
"Module not found" on one target only. You have only a .jms
file and ran --target=c, or only .cms and ran --target=js. Add
a .ms fallback (even if it just throws "not supported on this
target"), or provide the missing backend-specific file.
Forgot the .h extension. import { sqlite3 } from "sqlite3"
will not resolve — C headers always require the explicit .h. The
resolver never probes for .h on bare imports.
Circular imports across backends. client.cms imports client.ms
which imports back. The .ms side must not depend on the .cms /
.jms side — keep the arrow pointing inward toward shared code.
Extension on .ts is legal but unnecessary. Writing
"./utils.ts" works — the resolver sees the extension and uses the
file directly. Drop it when the basename is unique; write it when
you have two files with the same base name but different extensions
and you want to pin one.
8. Using existing TypeScript code
.ts files import naturally — drop the extension and the resolver
picks them up after .ms:
import { validate } from "./schemas"; // resolves to schemas.tsThe file is parsed as TypeScript and type-checked identically to
.ms. No wrapper, no @types/* drift.
npm packages by bare name do not resolve. The compiler doesn't run npm resolution. Two ways to use a library:
Option A — vendor the TS source. Copy the library's source into your project and import by path:
import { z } from "./vendor/zod";Works for any library that ships readable TypeScript source.
Option B — declare the surface with extern. For compiled JS,
closed source, or runtime-provided globals:
extern function fetch(url: string, init?: RequestInit): Promise<Response>;
extern const process: { env: Record<string, string> };On the JS target the runtime resolves these at load time. This is the idiomatic path for browser APIs, Node built-ins, and compiled npm packages — not a workaround.
9. Next steps
- Cross-target FFI — the
externand.himport mechanisms called out above. - Build configuration — declaring
targetsinbuild.mssomsc buildemits every target in one command. - Compiler Commands —
--target,--os,--cpuflags in detail.