Skip to content

Modules & Imports

Endo's module system lets you organize functions, types, and values into reusable, namespaced units. Modules follow F# conventions: PascalCase names, qualified access, and import-once semantics.

12.1 Inline Module Definitions

Modules can be defined inline using module Name = ...:

module Helpers =
    let double (x: int) : int = x * 2
    let triple (x: int) : int = x * 3

println (Helpers.double 5)   # => 10
println (Helpers.triple 4)   # => 12

All bindings inside the module are public and accessible via qualified access (Module.function). Inline modules persist across REPL prompts.

12.2 Multiple Inline Modules

You can define several modules and call across them freely:

module Math =
    let square (x: int) : int = x * x
    let cube (x: int) : int = x * x * x

module Utils =
    let inc (x: int) : int = x + 1
    let dec (x: int) : int = x - 1

println (Math.square 5)                  # => 25
println (Math.cube 3)                    # => 27
println (Utils.inc (Math.square 4))      # => 17

12.3 Opening Modules

open loads a module and brings all its public names into the current scope. Both qualified and unqualified access work after open.

module Math =
    let square (x: int) : int = x * x
    let cube (x: int) : int = x * x * x

open Math

println (square 5)        # => 25
println (cube 3)          # => 27
println (Math.square 4)   # => 16

Selective Open

open Module with (name1, name2, ...) imports only the listed names into unqualified scope. Qualified access to all public members still works.

module Math =
    let square (x: int) : int = x * x
    let cube (x: int) : int = x * x * x
    let double (x: int) : int = x * 2

open Math with (square, cube)

println (square 5)        # => 25
println (cube 3)          # => 27
println (Math.double 4)   # => 8

12.4 File-Based Modules

A .endo file is a module. The filename determines the module name (PascalCase convention). No module declaration is needed inside the file -- the filename is the module name.

For example, a file named Math.endo with the following content defines module Math:

let square (x: int) : int = x * x
let cube (x: int) : int = x * x * x

You can then load it with import Math and use Math.square 5.

Nested modules use directory structure. For import Geometry.Circle, Endo looks for:

Geometry/
    Circle.endo       # becomes Geometry.Circle
    Rectangle.endo    # becomes Geometry.Rectangle

12.5 Importing Modules

import loads a file-based module and makes its members available via qualified access. Members are not added to the unqualified scope -- you must always use the Module.name prefix.

# file: Math.endo
let square (x: int) : int = x * x
let cube (x: int) : int = x * x * x
import Math

println (Math.square 5)           # => 25
println (Math.cube 3)             # => 27

open can then bring names into scope (same as with inline modules):

import Math
open Math
println (square 5)                # => 25

12.6 Shell Keyword Harmony

import, open, and module are not reserved keywords. They are only recognized as module directives when followed by a PascalCase identifier (starting with an uppercase letter). This means common shell commands continue to work unchanged:

import requests

The line above is treated as a shell command (because requests is lowercase), not a module import. Similarly:

module load gcc

This is a shell command, not an inline module definition (because load is lowercase).

The following, however, defines an inline module (because Helpers is PascalCase):

module Helpers =
    let f (x: int) : int = x + 1

println (Helpers.f 5)   # => 6

12.7 Import-Once Semantics

Each module is compiled and its top-level bindings evaluated exactly once per session, regardless of how many times it is imported or opened.

The module cache is keyed by resolved absolute file path, so the same physical file loaded via different relative paths is still recognized as one module.

12.8 Module Resolution

When resolving import Foo, Endo searches these locations in order:

Priority Location Example
1 Relative to importing file ./Foo.endo
2 Each search path in order ~/.config/endo/modules/Foo.endo, <prefix>/share/endo/stdlib/Foo.endo

For nested modules like import Geometry.Circle:

Priority Location
1 ./Geometry/Circle.endo
2 ~/.config/endo/modules/Geometry/Circle.endo
3 <prefix>/share/endo/stdlib/Geometry/Circle.endo

12.9 REPL Usage

Modules work interactively in the REPL. Imported modules, opened names, and inline module definitions all persist across prompts:

$ module Quick =
>     let half (x: int) : int = x / 2
>
$ Quick.half 10
5
$ open Quick
$ half 20
10

12.10 Circular Dependency Detection

Endo detects circular dependencies at load time. If module A imports module B, which imports module A, a clear error is reported:

Error: Circular module dependency: ModA → ModB → ModA

12.11 Error Messages

Situation Error Message
Module not found Module 'Foo' not found. Searched: ./Foo.endo, ...
Circular dependency Circular module dependency: A → B → A
Unknown member Module 'Math' has no member 'nonexistent'
Private member 'Math.helper' is private and cannot be accessed outside module 'Math'
No module loader Module system not available (no module loader configured)
Selective open mismatch Module 'Math' has no member 'nonexistent' (in selective open)

12.12 Interaction with Built-in Modules

Built-in modules (File, Path, DateTime, Size, TimeSpan, etc.) take priority over user-defined modules of the same name. These are always available without an import statement:

let content = File.readAll "data.txt"
let size = File.size "data.txt"

User-defined modules require explicit import or inline module ... = definitions.

12.13 Running Scripts

Endo provides several ways to run .endo scripts:

Method Syntax Context
CLI endo script.endo Runs in a new shell session
Shell mode ./script.endo or path/to/script.endo Executes in-process (current context)
source source script.endo Shell builtin, current context
run_script run_script "script.endo" F# callable, current context

All methods set the source file path so that import statements within the script resolve relative to the script's location.

Additional module search paths can be added via the CLI:

endo --module-path=./libs script.endo

12.14 Creating Reusable Modules

To create a reusable module, write a .endo file with a PascalCase name and place it in one of the module search paths (e.g., ~/.config/endo/modules/).

Here is a self-contained example using inline modules that demonstrates the same patterns you would use with file-based modules:

module StringUtils =
    let rec repeat (acc: str) (s: str) (n: int) : str =
        match n with
        | 0 -> acc
        | _ -> repeat (acc + s) s (n - 1)

    let greet (name: str) : str = "Hello, " + name + "!"

println (StringUtils.repeat "" "ha" 3)       # => hahaha
println (StringUtils.greet "world")          # => Hello, world!

Or bring names into scope with open:

module StringUtils =
    let rec repeat (acc: str) (s: str) (n: int) : str =
        match n with
        | 0 -> acc
        | _ -> repeat (acc + s) s (n - 1)

open StringUtils with (repeat)

println (repeat "" "ho" 3)   # => hohoho

12.14 Summary

Feature Syntax Effect
Inline module module M = ... Define module in current file/REPL
Import (file) import Math Qualified access: Math.square 5
Open open Math Unqualified + qualified access
Selective open open Math with (square) Only listed names unqualified
Nested import import Geometry.Circle Load from Geometry/Circle.endo

See also: Functions | Standard Library | Error Handling