In Grain, the base unit of code is a module. Every file of Grain code you write contains a module and every line you write belongs to a module. For example a simple Hello World
program:
module Main
print("Hello World 🌾")
A module is similar to a namespace in other languages: it’s a static container for your code that helps you organize functionality into clean, logical units.
Modules, in grain serve many purposes:
- Naming - A module provides a fixed name that starts with a capital letter (here
Main
). That name is used throughout the language to refer to the code inside. - Organization - Modules let you structure your program into smaller, well-defined units instead of having everything exist within a global scope or file. Think namespaces in other languages.
- Abstraction - Modules serve as a simple layer of abstraction, letting you keep certain things private and provide exports to other modules.
Tip: It’s best practice to name a module after its file name.
Submodules
Grain also allows modules to contain submodules, providing further separation and organization within a single module. For example, an outline of a basic Http
library might look like this:
/** A simple library for working with HTTP. */
module Http
/** Utilities for working with HTTP requests. */
provide module Request {
provide let make = () => void // Example content
}
/** Utilities for working with HTTP responses. */
provide module Response {
provide let make = () => void // Example content
}
Submodules are best used for grouping related functionality within a single parent module. They are ideal for creating sub-APIs or organizing your code into logical sections. In the Http
example above, Http.Request
and Http.Response
represent distinct concepts that are both part of and closely related to the larger Http
module.
Key points
- Submodules do not replace the need for separate files — use files for large, independent features, and submodules for logical subdivisions within a module.
- Submodules help structure code while keeping it inside a single logical unit.
Http.Request
andHttp.Response
both remain part of theHttp
module. - Submodules can be nested as deeply as needed, but as a rule of thumb, nesting beyond two levels may indicate that the parent module is too broad.
Exposing Functionality
Modules in Grain can make parts of their content accessible to other modules. This allows you to control what is visible or hidden to the outside world. Providing functionality is a key part of designing a module and building a clean codebase.
Grain supports two ways to provide functionality: concrete definitions and abstract interfaces. The following sections introduce each concept.
Provide
In grain the provide
keyword is used to provide a value
, type
or submodule
to the outside world. Anything marked as provide
is accessible to other modules through the parent. For example:
Providing Values
module Main
module Example {
/* An exposed value */
provide let visible = 2
/* An internal value */
let hidden = 1
}
print(Example.visible) // 2
print(Example.hidden) // Error: Unbound value hidden in module Example
Providing Types
module Main
module Example {
/** An exposed type */
provide type A = Void
}
let a: Example.A = void
Providing Exceptions
module Main
module Example {
/** An exposed exception */
provide exception Failure(String)
}
throw Example.Failure("Fail")
Providing Submodules
module Main
module Example {
/** An exposed submodule */
provide module SubModule {
provide let content = 1
}
}
print(Example.SubModule.content) // 1
Providing After Definitions
You can also provide exports after their definition using this syntax:
module Main
module Example {
// Definitions
let value = 1
type T = A
exception E
module M {
provide let val = 1
}
// Deferred Provide
provide {
value,
type T,
exception E,
module M
}
}
Abstract
Grain allows abstract types, which expose a type without revealing its implementation. This is useful for hiding internal details while still providing a usable interface.
module Main
module Example {
provide type Value = Number
abstract type Id = Number
/** Creates a new value. */
provide let makeValue = () => 1: Value
/** Creates a new user id. */
provide let makeId = () => 1: Id
}
print(Example.makeValue() + 1) // 2
// Error: This expression has type `Example.Id` but an expression was expected of type Number
print(Example.makeId() + 1)
Abstract types are used in the standard library to hide the implementation of Map
, Set
, and other data types. They also guarantee that values are created and used through the module interface, which is useful for things like IDs.
Includes
Grain lets you include modules from other files to build libraries or split large projects. Use the include
keyword to bring in a module:
// Code inside ./b.gr
module B
provide let value = 1
// Code inside ./c.gr
module C
provide let value = 2
// Code inside ./a.gr
module A
from "./b.gr" include B // Include a module by it's name
from "./c.gr" include C as Aliased / Use `as` to alias a module
print(B.value) // 1
print(Aliased.value) // 2
Similar Foreign Concepts
Concept | Similarities | Differences |
---|---|---|
Classes | Both organize code while providing a method of encapsulation. | Modules have no runtime representation, cannot be instantiated, and do not form hierarchies; they are composed rather than inherited. |
Namespaces | Both act as static containers for code. | Modules exist in one place and cannot be extended later; namespaces can be reopened or extended in other files. |