Foi: a different kind of functional programming language
Foi is a programming language that pragmatically balances Functional Programming (FP) and imperative programming techniques. It pulls inspiration for various syntax and behaviors from a variety of languages, including: JS, Scala, Haskell, F#, Go, Clojure, Scheme, and others.
greetings("my friend");
// Hello, my friend!
defn greetings(who) {
def msg: `"Hello, `who`!";
log(msg)
}
The language is designed for general application programming purposes, but with a clear emphasis on FP (and de-emphasis on OOP). It's not trying to compete in performance or capability with systems languages like C or Rust. Eventually, Foi will compile to WASM so it should be usable for applications in a variety of environments, from the web to the server to mobile devices.
An important design goal is that a somewhat experienced developer -- especially one with both FP and imperative experience/curiosity -- should be able to sufficiently/fully learn Foi in a few days. It also shouldn't take reading thousands of pages of books or watching months of workshop videos to fully grasp the surface area of Foi.
Hopefully, without too much heavy orientation, code like this will settle into familiarity for you:
defn getFavoriteMovies(userID) ^(IO ~<< {
def movieIDs:: fetch(`"/movies/`userID`");
def movieTitles:: all(
movieIDs ~map (movieID) {
fetch(`"/movie/`movieID`")
}
~map (.)|,"title"|
);
def itemsHTML: (~fold)(
movieTitles,
"",
(html,title) { `"`html`<li>`title`</li>" }
);
::document.body.setHTML(`"<ul>`itemsHTML`</ul>")
});
getFavoriteMovies(123).run()
TL;DR
If you're already convinced and ready to jump in, you may want to check these out next:
- Foi vs JS Cheatsheet
- Foi vs JS code snippets
- (Mostly Complete) Foi Guide
- Formal Grammar (for language theory enthusiasts)
- Foi-Toy (CLI tool)
- Foi-Toy Online (Web tool)
But if you're still skeptical, please read on for more about the intent and design philosophy of the Foi language.
Mission
Foi promotes coherence and safety through declarative Functional Programming (FP) patterns as first class language features, while bridging (with familiar idioms) to those more experienced in traditionally imperative programming languages.
The rightâ„¢ things should be intuitive, the risky things should be apparent, and the problematic things should be difficult or impossible.
Foi is a language you write for other humans to read first. It's only a secondary benefit that the computer can understand the code and execute the desired operations.
Design Philosophy
Foi aims to be a novel mix of a variety of syntactic styles and ideas from various languages. One primary goal is for Foi features to have internal consistency with each other, built on self-evident (as much as possible!) semantics and mental models. As such, there will be parts of Foi that should look familiar and other parts that may feel quite unfamiliar at first.
The syntax and design decisions attempt to diverge enough from familiar languages to take useful steps forward, but not too much that Foi is unuseful or impractical.
defn factorial(n)
![n ?> 1]: 1
^(n*factorial(n-1));
// or (tail-recursive):
defn factorial(n,total:1)
![n ?> 1]: total
^factorial(n-1,n*total);
Programming language design is a delicate balance, and inevitably will be judged both on subjective aesthetics and on empirical outcomes. And it's impossible to design a perfect language that everyone loves at first glance. Some will appreciate Foi, others will dislike it.
To prepare for exploration of Foi, here are some aspects of the design philosophy:
-
Words vs Symbols I don't think a language should be all symbols. I struggle to memorize arbitrary symbol combinations just as much as anyone. But I similarly feel overburdened when a language is full of long lists of reserved keywords.
I dislike how these reserved keywords can visually appear indistinct from our variables/identifiers (save for syntax highlighting). I also dislike how keywords can conflict with very useful variable names in certain contexts.
Foi has a pretty short list of bare keywords, most of which are actually part of the type annotation system (
int
,bool
, etc). Foi has a variety of purely symbolic operators, like+
(addition, concatenation) and+>
(compose-left).But in compromise in between, there are several "named operators", that combine both a symbol and a word. In such cases, the symbol is always the first character, and helps visually distinguish from any other identifier (either built-in or user-defined).
Another example:
^
replaces thereturn
keyword. This is designed to be a single "return" signifier that stands out (it's vertically top-aligned), has a semantic signal (sending a value "up and out" from a function), but is short enough to not burden even the most concise of inline function expressions. -
Consistency Building on the last point, all boolean operators begin with the
?
symbol (or!
for the negated form), so they should be easily recognizable as such. This includes pure symbolic operators, like?=
(equality) /!=
(inequality) or?>
(greater-than), as well as named operators, like?and
(boolean-AND) and!or
(boolean-NOR) and?in
(included-in).The
?
and!
symbols can also serve as unary prefix operators on any identifier (or value), to cast the value to a boolean (and negate it, for!
). Again, these symbols always indicate boolean purposes. Consistency.Lastly, all decision making in Foi uses
?
and!
. For example, there's pattern-matching?{ .. }
/?(..){ .. }
-- a more powerful combination form ofif
..else if
andswitch
. There's also guard clauses?[ .. ]:
-- likeif
statements in front of statements. Finally, loop conditionals use the same form as guard clauses.Again, all these decision-making features consistently use the
?
/!
signifiers.Loops/comprehensions all use
~
as the first symbol, from the~each
/~map
named-comprehensions to the~<
chain/bind/flatMap operator.Lexical definitions come in three forms: variables, functions, and types. Accordingly, there's three corresponding keywords:
def
(variables),defn
(functions), anddeft
(types).These kinds of choices help visual distinction (from other identifiers/syntax) but also conceptually group related features/capabilities together, and hint/signal semantics.
-
Visual Semantics Many of the operators use symbol(s) with intended visual semantics.
For example,
+>
(compose left-to-right operator) has two semantic signals in it. First, the+
signifies a concatenation of functions (aka "composition"). Secondly, the>
symbol is pointing in the direction of data-flow through the functions. By contrast,<+
is the compose-right (right-to-left), meaning the data flows in the opposite direction.As mentioned previously,
^
points upward, to semantically hint "return value up/out". -
Operators As Functions If an operator has one or two operands (e.g.,
!x
,y + z
), it can be used in traditional infix/prefix form.But if the operand needs three or more operands, it must be invoked as a typical function call, with a
( .. )
delimited arguments list. This can be done by surrounding the operator in its own( )
, such as(*)(2,4,6,8)
(i.e.,2 * 4 * 6 * 8
); there must be no whitespace between the( )
and the operator inside it.In the same way, operators can be referenced as function values like any other identifiers, such as
def minus: (-); minus(3,1);
. -
Immutable Objects In Foi, Records / Tuples (both use
< .. >
syntax) are immutable.That means we don't overload
{ .. }
to mean both blocks and object literals -- a perpetual confusion in languages like JS -- nor[ .. ]
to mean both property accesses and array literals -- yet more confusion spawning.We also get rid of any syntax/capabilities around mutation of these values. But in place, we need ergonomic facilities for deriving new immutable Records/Tuples from existing ones.
For example,
< &one, &two, 10, 20 >
is a Tuple that includes the contents ofone
andtwo
Tuples, along with the values10
and20
. The&
operator (known as "pick") might evoke "pointer" / "reference" semantics from other languages. That's intentional here, as the mental model should be that thoseone
/two
immutable values are being linked to the new value rather than being copied (as...
does in JS). Similar for Records.The
&
capability also allows selective picking/linking, like< &two.3 >
< &one.something >
.Computed property names in JS again overload the
[ .. ]
syntax, like{ [someProp]: 42 }
. But in Foi, we simplify this with a single%
symbol:< %someProp: 42 >
. This is also how Records act as Maps (non-primitive keys):< %otherObj: .. >
.Foi treats a "Set" as just a filtered construction (removing duplicates) of a Tuple.
All these capabilities thus work identically for Records, Maps, Tuples, and Sets. That means you really just need to learn one data structure form:
< .. >
. -
Side Effects Since there are no mutable values in Foi, the most common type of (in-program) side effect (bug!) in programming is completely impossible.
The only possible in-program side effect is re-assignment of a variable (which is actually rarely the source of bugs, despite popular claims to the contrary). Foi allows such re-assignments, unlike many languages (especially FP languages) that have forms like
const
to disallow re-assignment.However, these re-assignment side effects must be declared if they cross a function boundary (via closure). You do so in the function signature, via the
:over
clause:defn myFn(x) :over(y) { y := x + z; // .. }
The compiler enforces this requirement: any lexical variable outside a function's scope (aka, "free variable") that appears as an assignment target must be added to the function's
:over
clause. Note: in the above snippet,z
is also an outer variable, but since it's only read (not assigned to), it does not need to be declared in the:over
clause.Moreover, Foi provides a very powerful mechanism for managing all side effects (in program and externally), called the
IO
monad.One key (external) side effect is asynchrony/concurrency. Foi additionally provides several in-language mechanisms for expressing and managing asynchrony, including
Promise
,PullStream
/PushStream
, and CSPChannel
. These are all monadic constructs, which allows strong mathematical guarantees about how you compose such behaviors. -
Errors Errors/exceptions are a complicated (and complicating!) topic in programming language design. Especially in FP, runtime exceptions are typically seen as side effects, and are thus usually de-emphasized or avoided altogether.
Foi's position is clear and firm: there are no runtime exceptions, and thus no need for
try..catch
style programming.Runtime errors can happen, but Foi always represents these as the monadic
Left
value. Thus, to program effectively in Foi, you will have to at least get familiar with handlingLeft
values (via~fold
/~cata
). -
Idiom Opinions There are a number of (hotly contested) idioms across programming languages that various developers either favor or shun. Often, general programming languages don't take strong positions on such subjective debates. Foi however does enforce a stance on some of them.
One key idiom is "early return", meaning that a function may conditionally return before the end of the function. In Foi, there can only be one
^
return statement, and it must be at the top level of a function's body (not inside some block or expression).This means, for example, there's no such thing as a
return
statement from inside a loop, or from inside a conditional (guarded expression or pattern match expression).However, Foi does recognize a valid subset of conditional "early return" use-cases. In fact, Foi insists that they should be elevated to be visible and obvious in the function declaration header, rather than buried in the middle of the function body.
These are basically conditions that can be checked before the function has even been fully invoked (but its arguments are computed and known); they do not require any internal function state to evaluate. In other words, these are "preconditions" that, if not met, obviate the need to run the function; its result (fixed or error) can be statically determined.
For example, a function may return an error (via
Left
monadic value) if the arguments passed in are invalid/insufficient for the function's expectations. Note: This is a runtime scenario, orthogonal to a static type mismatch the compiler could have discovered and failed a build. Another example is a "base condition" in a recursive function.Foi "preconditions" appear in the function signature to express these "early return preconditions", like this:
defn myFn(x) ?[x ?< 0]: 0 { .. } // or: defn myFn(x) ![x ?>= 0]: 0 { .. }
In this function signature, the
x
parameter is checked, and if it's less than0
, the fixed0
value is substituted ("early returned") in place of the function being invoked. -
Types In the Foi language, you don't annotate "container types" -- types on variables, properties, etc. Instead, you annotate "value types" -- types on values, and on expressions.
I believe we'll get most of the benefits of "typing" with much less syntactic and mental overhead compared to traditional language static types.
Foi employs gradual typing, meaning that type annotations (and implied typings from inferences) are validated at compile-time if possible; but if some typing assertions cannot be validated, they're left to be checked at run-time.
The Foi compiler will be configurable, to specify if you want to leave the run-time type assertions in, or strip them. For example, you may leave the run-time type assertions in for runs of your test suite, but not for your production distribution code.
This is not an exhaustive list of design persuasions, but it should help set the right perspective for evaluating/analyzing Foi code.
Original Design Motivations
Click to expand the following list of ideals that initially inspired the design for Foi.
Inspirational/Aspirational Design Ideas for Foi
- versioned, with no backwards-compat guarantee
- parsed, JIT'd, compiled (probably WASM), with full-fidelity AST (preserves everything including whitespace, comments, etc)
- semicolons and braces (both required) -- no ASI, no implicit blocks
- lexical and block scoped, functions are first-class and have closure
- side effects (non-local reassignments) must be explicitly declared in function signature
- no global scope (everything is in a module scope)
- no circular dependencies, only synchronous module initialization
- no references or pointers
- no class, nor prototype, nor `this`-awareness
- functional (tail-call optimized, pattern matching, curried function definitions, composition syntax, native monads, no exception handling, etc)
- no `const`
- no `let` -- block-scoped declarations are explicit syntax as part of the block
- function auto-hoisting, but no variable hoisting
- only one empty value
- numeric types: int, float, bigint, bigfloat
- everything is an expression (no statements)
- iteration/looping (for/foreach/filter/map) are syntactic expressions, but accept functions
- all keywords and operators are functions (with optional lisp-like call syntax)
- records/tuples (instead of objects/arrays) that are immutable and by-value primitives
- syntax for mutable data collection (dynamically define props/indices, like objects or arrays), but in order to use/read/pass-around, must first be "frozen" into an immutable record or tuple -- somewhat like a heap-allocated typed-array that's then accessed by a "view" (a record or tuple)
- strings are sugar for tuples of characters, and are interoperable as such
- optional named-argument call syntax
- asynchrony built in (syntax for future values and reactivity/streams)
- garbage collected
- type awareness: weakly typed (small, limited set of type coercions), with dynamic type inferencing as well as optional type annotations on values/expressions
In addition to the above, I may pull parts of a long-ago description of earlier ideas for this language (then called "FoilScript").
Exploring Foi
If you have experience or familiarity with JS, check out the Foi vs JS Cheatsheet, as well as some screenshots of Foi vs JS code snippets.
Additionally, Foi Guide is a detailed exploration of the language.
For implementers or language design enthusiasts, a formal grammar specification is in progress.
Tools
Foi is still being designed. As such, there's no official compiler/interpreter yet.
However, Foi-Toy is an experimental CLI tool for playing around with Foi code, prior to there being an official compiler.
Foi-Toy currently supports tokenizing Foi code, and syntax highlighting (via HTML/CSS) as shown in the above screenshots. Foi-Toy can also validate code against the formal language grammar.
Additionally, there's an online version of Foi-Toy to play around with tokenization, syntax highlighting, and validation.
License
All code and documentation are (c) 2022-2023 Kyle Simpson and released under the MIT License. A copy of the MIT License is also included.