FFI boundary philosophy

Status: the Rust compiler (rust/, cargo build --release -p sky) is the primary Sky compiler; the Haskell compiler is preserved under legacy-haskell-compiler/. Verified by the example sweep + compiler test suite (cargo test + xtask gates). See ../compiler/versions.md for the changelog.

The trust boundary

Sky's Go FFI is a trust boundary, not a transparent function call. Even when Go's signature looks safe (func F() string), the call crosses into code Sky's type checker can't see — the Go compiler is the only gatekeeper, and Go's type system permits panics, nil pointers, interface-nil values, OOM, goroutine leaks, and runtime errors that Sky's HM types can't model.

This is the same problem typed-airlock FFI designs (such as Elm's ports or PureScript's foreign-import boundaries) solve: typed airlocks to JavaScript that decode incoming values and reject what doesn't fit. Sky applies the same principle to Go: every FFI call returns Result Error T, forcing the user to acknowledge the boundary at each call site.

Why Result, not Task

Sky typeMeaningUse for
Result Error T"this crossed a boundary, here's the outcome"Synchronous FFI calls (already executed)
Task Error T"this will cross a boundary when you say go"Deferred Sky effects (File.readFile, Time.sleep)

FFI calls execute immediately — wrapping them in Task would imply "hasn't run yet" which is misleading. By the time Sky code sees the value, the Go function has already returned. Result accurately describes that state: the call happened, here's what came back.

If a user wants to defer an FFI call (compose it with Task.parallel or hold it lazily), they can wrap it explicitly:

deferred : Task Error String
deferred =
    Task.lazy (\_ ->
        case Uuid.newString () of
            Ok id -> Task.succeed id
            Err e -> Task.fail e
    )

Why Result on every FFI call (even pure-looking ones)

Three reasons:

  1. Go can panic anywhere. Even functions Go authors mark as pure can fail — third-party packages have bugs, nil pointers sneak in, init() in some imported package can leave global state broken, the runtime can hit resource limits. The Result wrapping turns every panic into a typed Err instead of a process crash. Sky's defer-recover layer (SkyFfiRecoverT) catches the panic at the wrapper boundary and surfaces it as Err(ErrFfi(...)).

  2. Honest types. A function that might fail returning bare T is dishonest. Result Error T matches what can actually happen at runtime. The user reads the type and knows: "this is across the boundary; check the outcome."

  3. Intentional friction. Discouraging Go FFI is a design feature. Sky's stdlib should grow to cover most use cases. The Result tax is a signal: "you're leaving Sky's safety guarantees, consider whether you really need to." When a Sky-side equivalent exists (Std.File, Std.Http, Std.Db, Std.Crypto, etc.) prefer it.

Comparison: Rust's ? and unwrap

Rust forces explicit handling of Result via the ? operator (propagate up the call stack) or .unwrap() (panic on Err for known-safe cases). Sky's pattern matching, Result.withDefault, Result.map, and Result.andThen serve the same role: every call site explicitly acknowledges the fallibility. There's no implicit unwrap — the compiler won't let you accidentally use a Result T as if it were T.

The difference: Rust's boundary is unsafe (memory safety). Sky's boundary is "untyped from Sky's perspective" — Go isn't unsafe in the memory-safety sense, but its type system doesn't surface enough information for Sky's HM to reason about every failure mode.

Comparison: typed-airlock FFI (e.g. Elm's ports)

PropertyTyped-airlock FFI (Elm ports as a familiar example)Sky FFI
Typed boundaryDeclared types both sidesSky declares; inspector extracts Go types
Failure containmentBad foreign data → decode errorBad Go return → Result Error T
AsyncOften async (Cmd outbound, Sub inbound)Synchronous (Sky compiles to Go and they share a process)
DecoderJson.Decode-style for incomingrt.Coerce[T] for shape mismatches
Crash safetyForeign-runtime errors can't reach the hostGo panics caught by SkyFfiRecoverT

Sky doesn't need a runtime-asynchronous airlock because it's not crossing a runtime boundary — Sky compiles to Go and they share a process. Synchronous calls fit Go's model. The other airlock properties (typed, contained, decoded, crash-safe) all apply.

What this means in practice

Prefer Sky stdlib

TaskSky stdlib (no Result tax for pure ops)Go FFI fallback
Generate UUIDSky.Core.Uuid.v4 / v7Uuid.newString (github.com/google/uuid)
HTTP requestSky.Core.Http.getHttp.get (net/http)
File readSky.Core.File.readFileOs.readFile
SQL queryStd.Db.querySql.dbQuery (database/sql)
HashSky.Core.Crypto.sha256Crypto.sha256.sum256
TimeSky.Core.Time.nowTime.now (time package)
JSON encode/decodeSky.Core.Json.Encode / DecodeJson.marshal
AuthStd.Auth.signToken(none)

When you need Go FFI, expect Result at every call site

-- Bad — ignores the boundary
let id = Uuid.newString () in ...   -- type error: id : Result Error String

-- Good — pattern match
case Uuid.newString () of
    Ok id ->
        ...
    Err e ->
        ...

-- Good — bail to a default
let id = Result.withDefault "anonymous" (Uuid.newString ()) in ...

-- Good — chain across multiple FFI calls
result =
    Uuid.newString ()
        |> Result.andThen (\id -> Db.insertUser id email)
        |> Result.andThen Session.create

For (T, bool) comma-ok returns, you handle two layers

Go's func F() (T, bool) (map lookups, type assertions, sync.Map.Load) maps to Result Error (Maybe T). The Result captures boundary failure (panic, type mismatch); the Maybe captures Go's "nothing here":

-- Looking up a key that may not exist
case SomeMap.get key of
    Ok (Just value) ->
        useValue value

    Ok Nothing ->
        -- Boundary call succeeded but the key isn't in the map
        useDefault

    Err e ->
        -- The FFI call itself failed (panic, etc.)
        logBoundaryFailure e

Bare *T returns are NOT auto-wrapped in Maybe

Many Go SDKs use builder patterns:

session, err := stripe.New(params).
    Customer(custID).
    LineItems(items).
    Confirm(ctx)

Each intermediate call returns *Builder. The pointer is conventionally non-nil — wrapping every hop in Maybe would force the user to unwrap at every step:

-- Hypothetical Maybe-wrapped chain (rejected design)
case Stripe.new params of
    Ok (Just s1) ->
        case Stripe.customer custID s1 of
            Ok (Just s2) ->
                case Stripe.lineItems items s2 of
                    Ok (Just s3) -> ...
                    ...

Sky's design: *T returns flow through as Result Error T. If the Go SDK genuinely returns nil and the user calls a method on it, the defer-recover catches the nil-deref panic and surfaces Err(ErrFfi("nil pointer...")). Go authors who explicitly mean "this can be nothing" use (T, error) or (T, bool) — those map cleanly to Result Error T / Result Error (Maybe T).

Method calls have nil-receiver guards

Every generated method/getter/setter wrapper checks for a nil receiver. A method call on an expired/closed/never-initialised opaque returns Err(ErrFfi "nil receiver: Type.Method") instead of panicking. The Result wrapping makes this visible at the type level — you can't accidentally .method() on a nil and crash the process.

When to add to Sky's stdlib instead of telling users to FFI

If a Go package's API is small, stable, and broadly useful (crypto primitives, time helpers, JSON, regex), prefer adding a Sky-side wrapper to sky-stdlib/ so users don't pay the Result tax for what's effectively pure code. The Std.* modules under sky-stdlib/ cover this: they wrap Go but expose pure Sky types for genuinely-pure operations (Std.Crypto.sha256 returns String, not Result Error String, because hashing can't meaningfully fail).

Reserve direct FFI exposure for: