Code split
MetaScript compiles one codebase to many targets — C, JS, WASM today,
more as the language grows. The compiler picks the right file or
function variant per --target; no runtime if-else. 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
The .ms file isn't a placeholder — it's where the LSP and checker
learn the canonical shape used across targets. When the real
implementations live in .cms / .jms, declare the same signatures
in a sibling .ms with unreachable; bodies. Tooling (hover,
go-to-definition, cross-target type checks) reads from there; skip it
and those degrade.
// 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; }The compiler still picks .cms or .jms at codegen; the .ms is
what tooling reads.
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 @target("js") {
const cache = (globalThis.process?.platform === "win32")
? "C:/tmp/app-cache"
: "/tmp/app-cache";
}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.