Ludwig Syntax Reference

Structure of a Composition

Ludwig compositions consist of expressions, such as imports, type definitions, and variable bindings. This very simple example shows us the structure of a typical composition:

# A composition statement

# An import
import Ludwig.Prelude

# A type definition
type Product:
  name:  String
  price: Int

# A binding
Product vanilla-milkshake:
  name:  "Vanilla Milkshake"
  price: 4


Bindings are the most common element in a Ludwig composition. A binding consists of a name, a body, and optionally a type annotation. It’s a good practice to add type annotations for all named function definitions, and wherever type annotations increase readability.

# With type annotation
String bar: "Hello world!"

# Without type annotation, but same binding, value, and type
bar: "Hello world!"

Bindings always start with a lowercase character.

Primitive types

Ludwig is statically-typed, which means that every expression has a type that is checked at compile time. Ludwig types can roughly be split into two categories: primitive types and user-defined types.

Let’s illustrate the primitive types.


A standard machine integer.


  • Int my-int: 1

A double-precision floating point number.


  • Float my-float: 3.14

A Unicode string surrounded by single or double quotes.

Strings can span multiple lines, and lines are joined by a single space character. Or, you can use \ on the next line to preserve new lines.


  • With double quotes: String my-string-1: "Hello world!"
  • With single quotes: String my-string-2: 'Hello world!'
  • Spanning multiple lines:
String my-long-string-1:
     "This is a line
     and this is a continuation"

String my-long-string-2:
     "This is a line
     \and this is a new line"

Boolean value.


  • Bool my-bool-1: True
  • Bool my-bool-2: False

Lists preserve ordering and are homogeneous, so they can contain values of any single type.


  • List of Ints: List<Int> my-list: [1, 2, 3]
  • This list would produce a compiler error because the values are not the same type: List<Int> my-mistake: [1, 2, "foo"]

Dictionaries have no explicit ordering; think of them as hashmaps. Keys are always strings, and values can be any primitive type. Like lists, dictionaries are homogeneous, so all values must be of the same type.


  • Dictionary of Ints: Dictionary<Int> my-dictionary: {"foo": 1, "bar": 2}

Binary data read from an external file.


  • Bytes my-bytes: Ludwig.Bytes.readFile("/etc/passwd")

Optional type. The Optional type “wraps” one of the above types, and just means that the value doesn’t have to be specified. You would usually see optionals within records.


Both of these are valid assignments (though not in the same composition):

  • Optional<Int> my-opt-int: Optional(23)
  • Optional<Int> my-opt-int: None

All types (primitive but also user-defined) start with an uppercase character.


Defining records

In addition to primitive types, it is possible to use records in Ludwig. A record can be declared in either JSON- or YAML-style syntax.

# JSON-style syntax
timmy: {
  name: "Timmy",
  age: 34

# YAML-style syntax
    name: "Tommy"
    age: 35

The types of these bindings are, unsurprisingly, record types. These are written like this:

{name: String, age: Int} timmy: {name: "Timmy", age: 34}

Types like this can become unwieldy to read and write very quickly. That is why the type keyword allows you to introduce an alias. An alias is just a synonym for a type. Here, in JSON-like syntax, you can see the alias Person standing in for the full record type in the third line:

type Person: {name: String, age: Int}

Person timmy: {name: "Timmy", age: 34}

In a similar way to the records themselves, we can also define types in YAML-like syntax in addition to the JSON-like syntax above.

type Person:
    name: String
    age: Int

Selecting from records

Fields can be selected from a record using the record.field-name syntax, e.g.:

Int timmys-age: timmy.age

Updating records

Records can be updated using the with keyword. Both JSON- and YAML-like syntax is available.

timmy-2: timmy with {age: 36, hobby: "Planking"}

tommy-2: tommy with
    age: 38
    hobby: "Taking selfies"

If a field does not yet exist in the record, it is added. This is why timmy-2 and tommy-2 are no longer of the type Person: they have an extra field, hobby.

Nested record values can also be updated. If we have the following types and person:

type Person:
  name: String
  age: Int
  address: Address

type Address:
  street: String
  number: Int

Person timmy-3:
  name: "Timmy"
  age: 34
    street: "Dubstrasse"
    number: 22

Then we can create a timmy-4 who lives at a different street number like so:

Person timmy-4: timmy-3 with { address.number: 33 }


Ludwig also has tuples:

my-tuple: ("Hello", 123, True)

And tuples can be given types:

(String, Int, Bool) my-typed-tuple: ("Hello", 123, True)

Algebraic data types

Defining algebraic data types

Ludwig supports algebraic data types. In the simplest form, these are just untagged enumerations:

type Color:
    | Red
    | Green
    | Blue

my-favorite-color: Green

However, they can also carry a value.

type ColorPreference:
        | NoPreference
        | SpecificPreference Color

    my-preference: SpecificPreference(my-favorite-color)

If multiple values are required, use a tuple or a record:

type Point: (Int, Int)

type MyEnumType:
    | NeedRecord {field1: Int, field2: String}
    # value types can be defined inline
    | NeedTuple (Int, String)
    # or in an type
    | NeedTuple2 Point

MyEnumType value1: NeedRecord({field1: 33, field2: "Hello"})
MyEnumType value2: NeedTuple((33, "Hello"))
MyEnumType value3: NeedTuple2((0,0))

Pattern matching

You can see what’s inside an algebraic data type or a tuple value by pattern matching:

points: case my-favorite-color of
    | Red   -> 10
    | Green -> 12
    | Blue  -> 15

type Point:
    | Origin
    | Point {x: Int, y: Int}

r1: case some-point of
    | (0, 0) -> Origin
    | (x, y) -> Point({x: x, y: y})

Pattern matching can be used to great effect within functions as well.

fun install {platform: Platform, packages: List<String>} -> String:
  case platform of
    | Debian -> String.join(" ", List.concat(["apt-get -q -y install"], packages))
    | RHEL x -> String.join(" ", List.concat(["yum -q -y install"], packages))

It is also possible to match on the values inside the constructors.

have-preference: case my-preference of
    | NoPreference         -> False
    | SpecificPreference _ -> True

preference-or-default: case my-preference of
    | NoPreference         -> Red
    | SpecificPreference c -> c

Using the example above of my-preference: SpecificPreference(Green):

  • have-preference(my-preference) evaluates to True, as my-preference is a SpecificPreference.
  • preference-or-default(my-preference) evaluates to Green for the same reason; if my-preference were the NoPreference value, the result would be a default of Red.

Record Updates Inside Constructors

We can also safely update values that may be inside of sum type constructors using the same familiar syntax for records. If we have this:

type Foo:
  | Foo {foo: String}

bar: Foo {foo: "foo"}

Then we can create a new binding by updating the interior foo value like so:

baz: bar with { "baz" }

Record Selects Inside Constructors

Similar to updating values inside of sum type constructors, we can also do selects. The restrictions here are that:

  1. The sum type only has a single constructor.
  2. That single constructor has a value inside.

This syntax is consistent with updates:

type Foo:
  | Foo {foo: String}

Foo bar: Foo({foo: "foo"})
String qux:



Ludwig supports defining and calling functions. Let’s look at a simple example of a function with an explicit type.

type Person:
    name: String
    age:  Int

fun get-name(p: Person) -> String:

We can call this function using familiar syntax:

example-name: get-name({name: "Samuel L. Jackson", age: 67})

There are two “flavors” of function in Ludwig. The one above is the positional argument flavor – noted by its use of parentheses () in the function definition. There’s also a named argument flavor, which is defined with curly braces {}. Usage is very similar, just that in the named argument flavor, the caller must use named arguments.

fun get-name-named{p: Person} -> String:

example-name-two: get-name-named {
  p: {name: "Dolph Lundgren", age: 58}

Anonymous functions

Ludwig also supports anonymous functions. For example:

get-anon: fun(p):

The type of this anonymous function is:

fun (Person) -> String

Function types are useful when we want to store functions in data types.

type Curve:
    name: String
    y:    fun(Int) -> Int

Curve quadratic:
    name: "The simple quadratic curve"
    y:    fun(x): x * x

Shorthand syntax

In most cases you must provide () (positional) or {} (keyword) when calling a function. However, there is a shorthand syntax available for calling functions that take a string or a record as a single argument.

foo1: get-anon{name: "Jon"}   # Same as get-anon({name: "Jon"})
foo2: String.length "Jon"     # Same as String.length("Jon")
foo3: String.length"Jon"      # Same as String.length("Jon")


In addition to Pattern matching, Ludwig also has traditional if-elif-else conditionals.

fun price(m: Milkshake) -> Int:
    if m.has-fancy-toppings then 5 else 4

You can put the branches on different lines, but in that case it is important that the indentation of if and else matches.

fun price-lines(m: Milkshake) -> Int:
    if m.has-fancy-toppings
    then 5
    else 4

Any number of elif expressions can be added between the if and else branches.

fun price-complex(m: Milkshake) -> Int:
    if   m.has-liquor         then 9
    elif m.has-fancy-toppings then 5
    else 4

However, there must always be an else branch. The following two examples are illegal:

fun price(m: Milkshake) -> Int:
    if m.has-fancy-toppings then 5

fun price(m: Milkshake) -> Int:
    if   m.has-liquor         then 9
    elif m.has-fancy-toppings then 5


Ludwig has some basic operators built in.

There is basic integer arithmetic, using the +, -, *, and / operators:

foo: 10 / 2

bar: foo + 1

There is a parallel set of floating point operators, which are like the integer operators but preceded with a ., namely .+, .-, .*, and ./.

There is string concatenation, using the ++ operator:

hello: "hello" ++ "world"

Ludwig also has the usual &&, ||, and ! boolean operators.

Local variables

You can define local variables within functions or bindings. To do this, we use the let keyword. The line after the let keyword must have the same indentation.

    let x: 1
    x + 2

In this example, foo will be bound to the value 3.

You can declare multiple local variables after one another. We also support type annotations and function declarations, very similar to top-level bindings.

    let fun add-some(x: Int) -> Int: x + 2
    let y: 1
    1 + add-some(y)

Annotations and Pragmas

There are a handful of field annotations and directive pragmas in the Ludwig language, and they use a similar syntax. Users of Fugue don’t need to write code using these annotations and pragmas, but some of them have relevant meaning when you read them. We’ll explain what annotations matter to you, and which don’t, below.


The @mutable annotation is one that appears in Fugue.* libraries. It indicates a field that, when changed in a composition, is modified by Fugue upon an update command. If the field is not labelled as @mutable, modifying it will cause the resource to be “destructively updated,” meaning that the resource will be replaced with a new one.


The @deprecated annotation also appears in Fugue.* libraries. It indicates that a given item is deprecated and may be applied to types, constructors, functions, operators, and even entire modules. If a composition references a deprecated item, the lwc compiler sends a warning to stderr, like this one:

ludwig (warning):
  "UseDeprecatedModule.lw" (line 1, column 8):
  module Deprecated-import1 is deprecated:

    1| import Deprecated-import1 as .

    Use another module.

lwc tolerates warnings by default, so if there are no errors, the composition still compiles. When using the lwc compiler by itself, you can turn warnings off with --no-warnings or opt to fail on warnings with --werror. See The Ludwig Compiler for more details.


The @proto annotation is one used internally by Fugue. It cannot be usefully applied by Fugue users at this time.


The @language pragma is generally only used by Fugue. It is primarily used within the Ludwig.Prelude modules to prevent them from importing themselves. There is no useful application of this pragma for Fugue users at this time.


What are modules?

A module in Ludwig is currently represented by a file. For example, the file Basic.lw provides the Basic module. Modules can be nested hierarchically using directories, so the file Network/Ip.lw provides the Network.Ip module.

As you can see, module names (and thus files containing modules) always start with an uppercase character.

In order to use such a module, we use the import keyword. For example,

import Basic
import Network.Ip

makes all the type definitions and data declarations in the files Basic.lw and Network/Ip.lw available in the present file. To refer to them, you would write or Alternatively, if writing the fully qualified name is a bit long, you can alias a module:

import Basic as B

This makes types and exports from Basic available under the prefix B; e.g., B.Foo. Finally, you can also import the names into the current scope:

import Basic as .

This means you can now just use foo instead of This is often very useful, although you have to watch out for naming conflicts.

If you have many imports, indentation-based syntax allows you to type the import keyword only once.

    Network.Ip as .
    Foo as F

There’s also some syntactic sugar for dealing with a lot of nested imports. Instead of writing:

    Fugue.AWS.Foo as Foo

you can use the following shorthand syntax:

import Fugue.AWS.{EC2, IAM, Foo as Foo}

Module directories

Where Ludwig looks for the files Basic.lw and Network/Ip.lw is determined by the search path. The compiler searches these paths in order and uses the first it finds:

  1. The current directory.
  2. The directories in the LUDWIG_PATH environment variable, e.g:
LUDWIG_PATH=lib/ludwig:~/.ludwig lwc main.lw

3. Additional directories passed in with an -i flag, e.g.
lwc -i lib/ludwig:~/.ludwig lwc main.lw

4. In /usr/local/ludwig, on POSIX systems.

If you need to know which files are actually used, you can dump this info by passing -dmodules to lwc.

Controlling exports

By default, everything in a module is exported. This can be fine-tuned by using export lists. If one or more export lists occur in the file, only the things explicitly mentioned in the lists are exported.

We use the export keyword to define such a list.

    type Person     # Export the type Person
    default-person  # Export a variable or function

default-person: ...

type Person: ...

You can also re-export whole modules. This is a bit more complicated, since we can nest them. Let’s look at an example. Imagine we have a module Mod.lw:

export module Foo as Foo  # Export in a nested way
export module Bar as .    # Export in this module's scope

import Foo
import bar

And then another module importing this Mod.lw:

import Mod

foo:  # Because Foo was exported as Foo in Mod
bar:      # Because Bar was exported as . in Mod


When running lwc with the --composition flag (which is the default when using Fugue), we expect a single composition as argument. A composition is a special kind of module, much like a “main” module in other languages.

A composition has a composition keyword, usually at the start of the module. For example, in MyComp.lw:


import Fugue.AWS

my-vpc: ...

If you use lwc with the -s simple semantic backend, all bindings declared in the composition are visible in the output, while bindings declared in other (imported) modules are not. Note: By default, lwc uses the null semantic backend, which produces no output if there are no errors or warnings.


Did you know that Fugue offers editor plug-ins so you can read and write Ludwig in your editor/IDE of choice? All plug-ins include Ludwig syntax highlighting, and some have additional features. See ludwig-mode for Emacs, ludwig-vim for vim, vscode-language-ludwig for VSCode, and language-ludwig for Atom.