Sigil FFI (Foreign Function Interface)
Overview
Sigil can call external modules (including TypeScript/JavaScript packages) using e (extern) declarations.
Syntax
e module::path
That's it. Exactly ONE way to do FFI (canonical form).
Examples
Console Output
e console
λmain()=>Unit=console.log("Hello from Sigil!")
Node.js Built-ins
e fs::promises
λmain()=>Unit=writeFile("output.txt","Hello, Sigil!")
λwriteFile(content:String,path:String)=>Unit=fs::promises.writeFile(path,content)
NPM Packages
First install the package:
npm install axios
Then use it:
e axios
λfetchUser(id:Int)=>Unit=axios.get("https://api.example.com/users/"+id)
λmain()=>Unit=fetchUser(123)
How It Works
1. Declaration
e module::path
Declares that you'll use an external module.
2. Usage
module::path.member(args)
Access members using full namespace path + dot + member name.
3. Validation
The compiler validates externals at link-time:
- Loads the module (requires
npm installfirst) - Checks if accessed members exist
- Fails BEFORE writing generated output if member not found
This catches typos WITHOUT needing type annotations!
4. Code Generation
e fs::promises
λmain()=>Unit=fs::promises.readFile("file.txt","utf-8")
Compiles to:
import * as fs_promises from 'fs/promises';
export async function main() {
return await __sigil_call("extern:fs/promises.readFile",
fs_promises.readFile, ["file.txt", "utf-8"]);
}
Namespace Rules
- Full path becomes namespace:
e fs::promises=> use asfs::promises.readFile - No conflicts possible:
moduleA/utilsandmoduleB/utilsare different namespaces - Slash visible in Sigil source (machines don't care about syntax aesthetics)
- Converted to underscores in generated TypeScript:
fs_promises.readFile
Validation Examples
✅ Works - Correct member
e console
λmain()=>Unit=console.log("works!")
❌ Fails - Typo in member
e console
λmain()=>Unit=console.logg("typo!")
Error: Member 'logg' does not exist on module 'console'
Available members: log, error, warn, info, debug, ...
Check for typos or see module documentation.
❌ Fails - Module not installed
e axios
λmain()=>Unit=axios.get("url")
Error: Cannot load external module 'axios':
Cannot find module 'axios'
Make sure it's installed: npm install axios
Type System Integration
Sigil supports both untyped and typed FFI declarations.
Untyped FFI (Trust Mode)
e console
e fs::promises
Uses any type for FFI calls. Member validation is structural (does it exist?) not type-based. This trust-mode any is an internal compiler escape hatch for untyped externs, not a general-purpose surface type you should write in Sigil source.
Typed FFI (Type-Safe Mode)
You can optionally provide type signatures for extern members:
t MkdirOptions={recursive:Bool}
e fs::promises:{mkdir:λ(String,MkdirOptions)=>Unit}
λensureDir(dir:String)=>Unit=fs::promises.mkdir(dir,({recursive:true}:MkdirOptions))
Benefits:
- Compile-time type checking of FFI calls
- Can reference named Sigil types in FFI signatures
- Better IDE/LSP support
- Self-documenting external APIs
Typed FFI relies on the same canonical structural equality rule used throughout the checker: unconstrained aliases and unconstrained named product types normalize before compatibility checks. That means MkdirOptions and {recursive:Bool} are treated as the same explicit type meaning when validating the mkdir call. Constrained user-defined types remain distinct. This is canonical semantic comparison, not type inference.
In project code, named user-defined types live in src/types.lib.sigil and are referenced elsewhere through µ.... The local same-file t MkdirOptions=... form shown here is still valid for standalone non-project snippets.
When modeling JavaScript data:
- fixed-shape objects should use records like
{recursive:Bool} - dynamic dictionaries should use core maps like
{String↦String}
Example: HTTP headers are maps, not records.
Syntax:
e module::path : {
member1 : λ(ParamType1, ParamType2) => ReturnType,
member2 : λ(ParamType3) => ReturnType
}
Declaration Ordering Requirement
IMPORTANT: Because typed extern declarations can reference named types, types must be declared before externs in Sigil's canonical ordering:
t MkdirOptions={recursive:Bool}
e fs::promises:{mkdir:λ(String,MkdirOptions)=>Unit}
e fs::promises:{mkdir:λ(String,MkdirOptions)=>Unit}
t MkdirOptions={recursive:Bool}
This is why Sigil's canonical declaration ordering is: t => e => c => λ => test
See [Canonical Declaration Ordering](/sigil/articles/005-canonical-declaration-ordering/) for more details.
Concurrent Behavior
Sigil uses one promise-shaped runtime model for FFI too. Promise-returning FFI calls are started automatically and joined only when a strict consumer needs their values:
e fs::promises
λmain()=>!Fs String=readFile("data.txt")
λreadFile(path:String)=>!Fs String=fs::promises.readFile(path,"utf8")
Compiles to:
import * as fs_promises from 'fs/promises';
function read_file(path) {
return __sigil_call("extern:fs/promises.readFile",
fs_promises.readFile, [path, "utf8"]);
}
export function main() {
return read_file("data.txt");
}
No Promise wrapping needed - it just works. The compiler keeps FFI results pending until something strict needs them.
See [ASYNC.md](/sigil/docs/async/) for the full async runtime and concurrent-region model.
Canonical Form
FFI has exactly TWO syntactic forms:
✅ ONLY: e module::path (untyped) ✅ ONLY: e module::path : { member : λ(...) => ... } (typed) ❌ NO: extern module::path (no full keyword) ❌ NO: e module::path as alias (no aliasing) ❌ NO: e module::path{member1,member2} (no destructuring)
This ensures deterministic, unambiguous code generation for LLMs.
Limitations
No Direct Object Construction
❌ Cannot: new Date()
❌ Cannot: new RegExp(pattern)
Must use factory functions or FFI wrappers.
No Method Chaining (Yet)
❌ Cannot: axios.get(url).then(fn)
Each FFI call is a single member access.
Future: Expression-level member access.
No Class Interop (Yet)
❌ Cannot: class instances
❌ Cannot: this binding
Use functional APIs or wrapper functions.
Best Practices
1. Wrap FFI in Sigil Functions
e console
λerror(msg:String)=>Unit=console.error(msg)
λlog(msg:String)=>Unit=console.log(msg)
λmain()=>Unit={
l _=(error("Error message"):Unit);
log("Info message")
}
2. Use Semantic Names
e fs::promises
λreadFile(path:String)=>Unit=fs::promises.readFile(path,"utf-8")
λwriteFile(content:String,path:String)=>Unit=fs::promises.writeFile(path,content)
3. Validate at Boundaries
Use contracts (future feature) to validate FFI inputs/outputs.
4. React and Browser Apps (Bridge Pattern)
Recommended frontend integration:
- Put deterministic domain policy in Sigil (
.sigil) - Compile Sigil to generated TypeScript (
.ts) - Use a separate
bridge.ts/bridge.tsxfor React hooks, JSX, browser events, and localStorage
Why keep a separate bridge?
- Linting/prettier/typechecking work normally
- React stays idiomatic
- Sigil stays canonical and machine-first
- UI/runtime glue is isolated from core logic
Future Extensions
- Type annotations for FFI declarations
- Method chaining syntax
- Class/object interop
- Callback conversions (JS => Sigil functions)
FFI unlocks the TypeScript/JavaScript ecosystem for Sigil programs.