Go Scripting with Expr Lang: 10 Critical Gotchas You Must Know

Go Scripting with Expr Lang: 10 Critical Gotchas You Must Know

Table of Contents

Expr is a fast, safe expression evaluation engine for Go. Use it for embedding dynamic filters, user-defined logic, or lightweight Go scripting in your applications.

When I first used Expr Lang, I was immediately impressed by how easily it dropped into a Go project. It offers clean syntax and strong performance, making integration seamless.

But as I began using it seriously, I encountered unexpected limitations. These things were not obvious from the official documentation. Some subtle behaviors quickly tripped me up. Others made debugging more difficult than I expected.

This article covers the lessons I learned the hard way. If you plan to use Go Expr in production, these tips will help you avoid common mistakes and get more out of it from day one.

Info

Avoiding Dependency Hell is critical for any production-ready library. This is where Expr excels: it is zero-dependency. It relies only on the Go standard library.

This means you avoid pulling in huge external scripting runtimes—no Lua, no JS, no external packages. You avoid deep, transitive dependency conflicts entirely.

To understand the significant risks this design choice avoids, see my detailed guide on 5 Strategies to Avoid Dependency Hell.

1. What Kind of Language Is Expr?

The Expr lang is not a full-blown scripting language. It is a functional, expression-only language with key limitations.

1.1. Expression-Only & Functional

The whole program in Expr lang is a single expression. There are no packages, no modules, and no function definitions.

Instead, writing an Expr script feels like implementing a single function. This function takes input data and a set of callable function identifiers. It must return a single value.

  • No return keyword exists.
  • No variable reassignment is allowed (more on that below).
  • No loops are available.
  • You cannot define user functions inside the script.

The absence of loops ensures that expressions always terminate. This prevents infinite loops or resource exhaustion.

Warning

Mind however the custom functions that are called from Expr. They can still introduce side effects or infinite loops if you do not implement them carefully.

1.2. Seamless Integration with Go Types

You do not need to perform special actions to pass Go variables into Expr.

You can even access methods on your Go types. For example, if you have a Go struct User with a method IsAdmin(), you can call user.IsAdmin() directly in your Expr script.

The expr.Env option allows you to pass an input as map[string]any or a struct. Expr automatically makes its fields and methods available in the script.

This makes exposing your Go data structures to the expression engine very convenient. If you pass a User struct with a field Name, for instance, you can access user.Name directly.

This seamless integration simplifies data access and manipulation. This is a significant advantage over other Go scripting alternatives requiring more boilerplate.

This is a major selling point for the Go Expr module and one of its strongest features.

Example of expr interaction with Go

Let’s look at an example.

 1package main
 2
 3import (
 4  "fmt"
 5  "github.com/expr-lang/expr"
 6)
 7// Define a Go struct and method
 8type User struct {
 9  Name string
10  Age  int
11}
12func (u User) IsAdmin() bool {
13  return u.Name == "admin"
14}
15// Define an external Go function
16func double(x int) int {
17  return x * 2
18}
19
20func main() {
21  user := User{Name: "Alice", Age: 30}
22  env := map[string]any{"user": user, "double": double}
23
24	program, _ := expr.Compile(
25    // Access struct fields, call external function, call Go method
26    `user.Name + " is not " + string(double(user.Age)) + " years old and is admin: " + string(user.IsAdmin())`, 
27    expr.Env(env),
28  )
29	output, _ := expr.Run(program, env)
30	fmt.Println(output) // [Output]: Alice is not 60 years old and is admin: false
31
32	user = User{Name: "Bob", Age: 25}
33	env = map[string]any{"user": user, "double": double}
34	output, _ = expr.Run(program, env)
35	fmt.Println(output) // [Output]: Bob is not 50 years old and is admin: false
36}

2. Expr Language Behavior and Gotchas

Here are common language-level surprises you face when working with Expr.

2.1. Local Map Keys Must Be String Literals

Maps created within the Expr script only support string keys at initialization.

If you use int keys, Expr silently coerces the keys to strings.

You cannot use variables or expressions as keys. Any name is treated literally as a string key.

You can access map values using variables as keys. However, you cannot access them directly using integer keys.

1let k = "mykey";
2let data = {k: 12, 25: 'Hi'};
3
4data[k] // ❌ Invalid, key is literally "k", will be nil
5data["mykey"] // ❌ same as k
6data["k"] // ✅ Works and is 12
7
8data[25] // ❌ Compile error
9data["25"] // ✅ Works and is 'Hi'
Example of Local Map Key Gotchas
 1package main
 2
 3import (
 4  "fmt"
 5  "github.com/expr-lang/expr"
 6)
 7
 8func main() {
 9  env := map[string]any{}
10
11  program, _ := expr.Compile(`
12    let k = "mykey";
13    // Keys k, 2, and "1" are all treated as string literals
14    let data = {k: 12, 2: 34, "1": 56};
15    // data["k"] works, data[k] and data["mykey"] fail as the literal key is "k"
16    [data[k], data["mykey"], data["k"], data["2"], data["1"]]
17  `, expr.Env(env))
18  output, _ := expr.Run(program, env)
19  fmt.Println(output)
20
21  program, err := expr.Compile(`
22    let data = {1: 12};
23    data[1]
24  `, expr.Env(env))
25  if err != nil {
26    fmt.Println("COMPILE ERROR:", err)
27  }
28}

Output:

[<nil> <nil> 12 34 56]
COMPILE ERROR: cannot use int to get an element from map[string]interface {} (3:10)
 |     data[1]
 | .........^

2.2. Environment Map Keys Retain Strict Go Types

Unlike maps created within the Expr script, Go maps passed to Expr retain their original key types.

In the script, if the Go map key is an integer, accessing it using a string key will fail.

1env := map[string]any{
2  "data": map[any]any{
3    1: "one",
4  },
5}
1data[1] // ✅ Work
2data["1"] // ❌ Fails (if Go map key is int)
Example of Map Key Types from env
 1package main
 2
 3import (
 4  "fmt"
 5  "github.com/expr-lang/expr"
 6)
 7
 8func main() {
 9  env := map[string]any{
10    "data": map[int]string{1: "one"}, // Go map uses int keys
11  }
12
13  program, _ := expr.Compile(`data[1]`, expr.Env(env)) // Access with int works
14  output, _ := expr.Run(program, env)
15  fmt.Println(output)
16
17  _, err := expr.Compile(`data["1"]`, expr.Env(env)) // Access with string fails
18  if err != nil {
19    fmt.Println("COMPILE ERROR:", err)
20  }
21}

Output:

one
COMPILE ERROR: cannot use string to get an element from map[int]string (1:6)
 | data["1"]
 | .....^

2.3. Variables Are Immutable

Once you assign a variable in Expr lang (from the env or a variable declaration with let), you cannot modify it.

1let x = 10;
2x = 20; // ❌ Error: assignment to constant

Even maps behave as immutable:

1let myMap = {"a": 1};
2myMap["a"] = 2; // ❌ Error: map is read-only

You cannot mutate any value inside the expression. The environment (env) is strictly read-only.

Illustration of Variables immutability
 1package main
 2
 3import (
 4  "fmt"
 5  "github.com/expr-lang/expr"
 6)
 7
 8func main() {
 9  env := map[string]any{
10    "data": map[string]int{"a": 1},
11  }
12  // Attempting to assign within the expression fails compilation
13  _, err := expr.Compile(`data["a"] = 2`, expr.Env(env))
14  if err != nil {
15    fmt.Println("COMPILE ERROR:", err)
16  }
17}

Output:

COMPILE ERROR: unexpected token Operator("=") (1:11)
 | data["a"] = 2
 | ..........^

2.4. The Access Trap: Structs are Strict, Maps are Forgiving

One of the most interesting and initially confusing aspects of Go Expr is that structs and maps share the same access syntax.

Whether you use dot notation (data.field) or bracket notation (data["field"]), both work for structs and maps:

1user.Name
2user["Name"]

This symmetry makes structs and maps look like they behave identically. They do not.

The difference appears when you try to access a non-existent field or map key.

  • For maps, Expr acts like Go: accessing a missing key returns the zero value of the expected type (e.g., "", 0, nil, etc.).
  • For structs, Expr acts strictly: accessing a missing field throws an expression compilation error.
Example of Struct vs Map Access
 1package main
 2
 3import (
 4    "fmt"
 5    "github.com/expr-lang/expr"
 6)
 7
 8type User struct {
 9    Name string
10}
11
12func main() {
13    // Setup env with a Go struct and a Go map
14    env := map[string]any{
15        "user": User{Name: "Alice"},
16        "data": map[string]string{"Name": "Bob"},
17    }
18
19    // Map access of a missing key returns zero value ("")
20    programMap, errMap := expr.Compile(`'"' + data.Missing + '"'`, expr.Env(env))
21    outputMap, errMap := expr.Run(programMap, env)
22    fmt.Println("MAP: output is", outputMap, "error is", errMap)
23
24    // Struct access of a missing field fails compilation
25    _, errStruct := expr.Compile(`'"' + user.Missing + '"'`, expr.Env(env))
26    fmt.Println("STRUCT: COMPILE ERROR IS:", errStruct)
27}

Output:

MAP: output is "" error is <nil>
STRUCT: COMPILE ERROR IS: type main.User has no field Missing (1:12)
 | '"' + user.Missing + '"'
 | ...........^

Notice what happens:

  • Accessing user.Missing on the struct causes an error.
  • Accessing data.Missing on the map returns the zero value, an empty string (""), and does not throw an error.

So even though you can use the same syntax, remember this rule:

Structs are strict; maps are forgiving.

This distinction easily goes unnoticed. It is especially tricky when your environment mixes struct types and dynamic maps.

Always check your data types before you assume “field” access will behave the same way.

3. Go Expr Integration Quirks

Let’s look now at surprises that arise when integrating Expr with Go code.

3.1. Functions/Objects Must Be Passed at BOTH Compile-Time and Runtime

If you define a custom environment function like isAdmin, you must provide it at both compile-time and run-time.

If you pass the function only at runtime, it will not work. The same applies if you pass it only at compile time.

This behavior differs from some other template engines, which allow runtime-only injection.

For environment objects (like user), a similar rule applies. You must pass them at compile time. If you do not pass them at runtime, they default to nil.

Example of Compile + Runtime Environment Requirement
 1package main
 2
 3import (
 4  "fmt"
 5  "github.com/expr-lang/expr"
 6)
 7
 8type User struct {
 9  Role string
10}
11
12func main() {
13  env := map[string]any{
14    "isAdmin": func(u User) bool { return u.Role == "admin" },
15    "user":    &User{Role: "admin"},
16  }
17
18  // Correct usage: Compile and Run with env containing both function and object
19  program, _ := expr.Compile(`isAdmin(user)`, expr.Env(env))
20  output, _ := expr.Run(program, env)
21  fmt.Println(output)
22  
23  // 'user' defaults to nil because it is missing from the environment map
24  output, err := expr.Run(program, map[string]any{})
25  if err != nil {
26    fmt.Println("RUNTIME ERROR:", err)
27  }
28  output, _ = expr.Run(program, map[string]any{"isAdmin": func (a any) bool { fmt.Println("a =", a);return false }} ) // ❌ Missing user
29  fmt.Println(output)
30
31// Fails at compile time because 'isAdmin' is missing from the Env option
32  program, err = expr.Compile(`isAdmin(user)`, expr.Env(map[string]any{})) // ❌ Missing isAdmin
33  if err != nil {
34    fmt.Println("COMPILE ERROR FUNC:", err)
35  }
36  // Fails at compile time because 'user' is missing from the Env option
37  program, err = expr.Compile(`user`, expr.Env(map[string]any{})) // ❌ Missing user
38  if err != nil {
39    fmt.Println("COMPILE ERROR OBJECT:", err)
40  }
41}

The output is:

true
RUNTIME ERROR: reflect: call of reflect.Value.Type on zero Value (1:1)
 | isAdmin(user)
 | ^
a = <nil>
false
COMPILE ERROR FUNC: unknown name isAdmin (1:1)
 | isAdmin(user)
 | ^
COMPILE ERROR OBJECT: unknown name user (1:1)
 | user
 | ^

3.2. Only Exposed (Public) Struct Fields are Accessible

When you pass a Go struct to Expr, only its exported (public) fields are accessible within the expression. Unexported (private) fields are ignored.

This is a standard Go visibility rule. Remember this when you design your structs for use with Expr.

If your Expr script requires a field of a struct to start with lowercase for compatibility or convention, you can use struct tags (expr:"fieldName") to alias them. For example:

1type User struct {
2  Name string `expr:"name"`
3}

In this case, you cannot access the original Name field, only the aliased name because the original field is now masked by the tag.

Example of non-exposed Struct field with tag
 1package main
 2
 3import (
 4  "fmt"
 5  "github.com/expr-lang/expr"
 6)
 7
 8type User struct {
 9    Name string `expr:"name"` // Exported field, aliased to 'name' in expr
10    age  int    // Unexported field, not accessible
11}
12
13func main() {
14    user := User{Name: "Alice", age: 30}
15    env := map[string]any{"user": user}
16
17    program, _ := expr.Compile(`user.name`, expr.Env(env))
18    output, _ := expr.Run(program, env)
19    fmt.Println(output) // Output: Alice
20
21    _, err := expr.Compile(`user.Name`, expr.Env(env))
22    fmt.Println(err) // Error Output: type main.User has no field Name (1:6)
23}

Output:

Alice
type main.User has no field Name (1:6)
 | user.Name
 | .....^

3.3. You Can’t Construct Go Structs in the Script

You cannot construct a Go struct in the Expr script:

1let u = User{name: "John", age: 42}; // ❌ Invalid

If you need Expr to return a Go struct, you must:

  1. Define a Go function like newUser(name, age) in your environment.
  2. Return the struct from that function.

This is a key limitation of the language being expression-only and sandboxed.

Example of Struct Construction Limitation
 1package main
 2
 3import (
 4  "fmt"
 5  "github.com/expr-lang/expr"
 6)
 7
 8type User struct {
 9  Name string
10  Age  int
11}
12// Helper function to create the struct in Go
13func newUser(name string, age int) User {
14  return User{Name: name, Age: age}
15}
16
17func main() {
18  env := map[string]any{
19    "newUser": newUser,
20  }
21
22  // Attempting to construct the struct in expr fails compilation
23  program, err := expr.Compile(`User{name: "John", age: 42}`, expr.Env(env))
24  if err != nil {
25    fmt.Printf("COMPILE FAILED STRUCT: %v\n", err)
26  }
27
28  // Calling the Go helper function works
29  program, _ = expr.Compile(`newUser("Alice", 30)`, expr.Env(env))
30  output, _ := expr.Run(program, env)
31  fmt.Println(output)
32}

Output:

COMPILE FAILED STRUCT: unexpected token Bracket("{") (1:5)
 | User{name: "John", age: 42}
 | ....^
{Alice 30}

Tip

Another workaround consists in returning a map from Expr and loading it into a struct using a struct mapping library like mapstructure.

This is a library that is very handy for loading configuration into Go structs.

3.4. Methods Must Return a Value

When you expose a Go method to Expr, the expression implicitly expects it to return a value it can use.

If the method signature has an empty return (i.e., it returns nothing), calling it from Expr results in a compilation error (runtime error in older versions, where the expression tries to read a non-existent return value).

If you need a side-effect-only method, make sure it returns a placeholder value like bool or nil. This value can then be safely ignored in the expression.

Example of Method Return Value Requirement
 1package main
 2
 3import (
 4  "fmt"
 5  "github.com/expr-lang/expr"
 6)
 7
 8type Executor struct {
 9  Count int
10}
11// Method returns nothing 
12func (e *Executor) Increment() { 
13  e.Count++
14}
15
16func main() {
17  env := map[string]any{"exec": &Executor{Count: 10}}
18  
19  // Expression calls the non-returning method, causing compile failure
20  expression := `exec.Increment() == nil` 
21
22  _, err := expr.Compile(expression, expr.Env(env))
23  if err != nil {
24    fmt.Printf("COMPILE FAILED: %v\n", err)
25  }
26}

The output will be similar to:

COMPILE FAILED: func Increment doesn't return value (1:6)
 | exec.Increment() == nil
 | .....^

3.5. No Signature Checking for Raw Functions

expr.Function functions do not have automatic signature validation. These are functions passed via the expr.Function(...) option, not those inside the env.

This means:

  • You won’t get a compile-time error if you call them with the wrong arguments.
  • The error will occur at runtime—or it may silently fail, depending on the usage.

Always thoroughly test your custom raw functions.

Example of No Signature Checking for Raw Functions
 1// Example of a function without signature checking
 2package main
 3
 4import (
 5  "fmt"
 6  "strconv"
 7  "github.com/expr-lang/expr"
 8)
 9
10type User struct {
11  Role string
12}
13
14func (u *User) MyMethod(arg1 string) bool {
15  return u.Role == arg1
16}
17
18func MyCustomFunc(u User, arg2 string) bool {
19  return u.Role == arg2
20}
21
22func main() {
23  env := map[string]any{
24    "MyCustomFunc": MyCustomFunc,
25    "user":         &User{Role: "admin"},
26  }
27
28  // Compile-time checking works for methods and env functions:
29  // These fail to compile because an argument is missing:
30  program, err := expr.Compile(`user.MyMethod()`, expr.Env(env))
31  if err != nil {
32    fmt.Printf("COMPILE FAILED METHOD: %v\n", err)
33  }
34
35  // This will Fail to compile because arg2 is missing
36  program, err = expr.Compile(`MyCustomFunc(user)`, expr.Env(env))
37  if err != nil {
38    fmt.Printf("COMPILE FAILED FUNCTION: %v\n", err)
39  }
40
41  // Custom 'atoi' function added via expr.Function() option
42  atoi := expr.Function(
43    "atoi",
44    func(params ...any) (any, error) {
45      return strconv.Atoi(params[0].(string)) // Accesses params[0] without checking length
46    },
47  )
48
49	program, err = expr.Compile(`atoi()`, expr.Env(env), atoi)
50	if err != nil {
51		fmt.Printf("COMPILE FAILED FUNCTION OPTION: %v\n", err)
52	}
53
54  output, err := expr.Run(program, env)
55  if err != nil {
56    fmt.Printf("RUN FAILED FUNCTION OPTION: %v\n", err)
57  }
58
59  fmt.Println(output)
60}

The output is:

COMPILE FAILED METHOD: not enough arguments to call MyMethod (1:6)
 | user.MyMethod()
 | .....^
COMPILE FAILED FUNCTION: not enough arguments to call MyCustomFunc (1:1)
 | MyCustomFunc(user)
 | ^
RUN FAILED FUNCTION OPTION: runtime error: index out of range [0] with length 0 (1:1)
 | atoi()
 | ^
<nil>

3.6. Do Not Pass a Pointer to a Map (*map[K]V)

Expr automatically dereferences pointers to structs (e.g., *User). However, passing a pointer to a map (*map[string]string) leads to confusing, unexpected behavior when you access keys.

The Go Expr engine correctly dereferences the pointer to get the map object.

If you attempt to access a non-existent key, Expr erroneously returns the entire dereferenced map object instead of the map’s zero value (e.g., an empty string "" or nil).

The expression should have returned the zero value for a map string element (which is ""). Instead, it returned the entire map!

This is incorrect because the return type should have been an empty string (""), the zero value for the element type.

Always pass maps by value (i.e., map[string]string) to Expr. This ensures correct zero-value retrieval on non-existent keys.

Example of Pointer to Map Bug
 1package main
 2
 3import (
 4  "fmt"
 5  "github.com/expr-lang/expr"
 6)
 7
 8func main() {
 9  data := map[string]string{"key1": "value1"}
10  
11  // Pointer to the map is passed in the environment
12  env := map[string]any{"dataPtr": &data}
13  
14  // Expression attempts to access a NON-EXISTENT key
15  expression := `dataPtr["nonexistent"]` 
16
17  program, _ := expr.Compile(expression, expr.Env(env))
18  output, err := expr.Run(program, env)
19  
20  if err != nil {
21    fmt.Println("Error:", err)
22    return
23  }
24  
25  fmt.Printf("Expression: %s\n", expression)
26  // Output shows the type of the whole map, not the element type (string)
27  fmt.Printf("Result Type: %T\n", output) 
28  fmt.Printf("Result Value: %v\n", output)
29}

The output shows the bug:

Expression: dataPtr["nonexistent"]
Result Type: map[string]string
Result Value: map[]

4. Final Thoughts and Pro Tips

Expr is a powerful expression engine, but it is not a full scripting language. Knowing its constraints will help you avoid bugs, confusion, and wasted time.

Here’s a quick summary of what to keep in mind for Go scripting with Expr:

  • Constraint: It’s expression-only—expect no packages, modules, or function definitions.
  • Immutability: No variable mutation is supported—this includes map updates.
  • Map Key Trap: Map keys are interpreted differently, depending on whether they are defined in Expr Lang or Go.
  • Struct/Map Access: Struct access is strict (compile error on missing field); Map access is forgiving (returns zero value).
  • Environment Dual Pass: Functions and objects must be passed at both compile-time and runtime.
  • Visibility: Only Go’s exported (public) struct fields are accessible.
  • Construction Limit: You cannot create Go structs inline in scripts.
  • Method Return: Methods must return a usable value (no side-effect-only methods).
  • Raw Function Risk: expr.Function calls lack automatic argument validation.

If you are coming from languages like Go, JavaScript, or Python, these constraints can be surprising at first.

Once you are aware of them, you can use Expr effectively for dynamic logic, user rules, filters, and more in your Go applications.

Tip

💡 Pro-Tip: Syntax Highlighting For better editor experience, use Rust syntax highlighting (.rs). The symbols and logic flow elements in Expr are largely compatible with standard Rust syntax.

Expr Lang is not only supported for Go. A Rust implementation is currently available. This allows you to use the same implemented logic across different services written in Go and Rust.

🔗 Useful links:

Related Posts

Elements of Computer Programs and Programming Languages

Elements of Computer Programs and Programming Languages

What can we liken computer programs to? To me, they’re like instruction manuals. From a functional perspective, an instruction manual provides …

Read More
Enums vs. Constants: Why Using Enums Is Safer & Smarter

Enums vs. Constants: Why Using Enums Is Safer & Smarter

Are you still using integers or strings to represent fixed categories in your code? If so, you’re at risk of introducing bugs. Enums provide …

Read More