Skip to content

Migrating to Rad v0.9

Version 0.9 unifies rad block keywords, introduces changes to error handling operators, renames a built-in function, shortens parse_epoch unit names, and enables invocation logging by default. This guide covers what changed and how to update your scripts.

Breaking Change: request and display Keywords Removed

What Changed

The three separate rad block keywords (rad, request, display) have been unified into a single rad keyword. The rad keyword now dispatches on the source type at runtime:

  • URL string - fetches JSON from the URL (replaces request)
  • List or map - extracts data in-memory (replaces display)
  • No source - operates on existing variables (replaces sourceless display)

Old Syntax (No Longer Works)

request "https://api.example.com/users":
    fields Name, Age

display data:
    fields Name, Age

New Syntax

rad "https://api.example.com/users":
    noprint
    fields Name, Age

rad data:
    fields Name, Age

Important: request Blocks Never Printed

request blocks only fetched data and populated fields - they never printed a table. The unified rad block prints by default, so when migrating from request you'll typically want to add noprint to preserve the old behavior.

display blocks already printed, so replacing display with rad requires no other changes.

Migration Steps

  1. Replace request with rad and add noprint if you don't want table output
  2. Replace display with rad - no other changes needed
  3. Run rad check on your scripts to catch any remaining uses

Breaking Change: get_stash_dir Renamed

What Changed

get_stash_dir was renamed to get_stash_path to better reflect its behavior - it returns a path (often to a file), not necessarily a directory.

Old Syntax (No Longer Works)

path = get_stash_dir()
path = get_stash_dir("data/config.json")

New Syntax

path = get_stash_path()
path = get_stash_path("data/config.json")

Migration Steps

  1. Find all uses of get_stash_dir in your scripts
  2. Replace with get_stash_path - the signature and behavior are identical

Breaking Change: ?? Now Fires on Null

What Changed

?? is now a true null-coalescing operator. It fires when the left side is null or an error, where previously it only fired on errors.

Before (v0.8)

m = {"key": null}
print(m["key"] ?? "fallback")   // printed: null
print(null ?? "default")         // printed: null

After (v0.9)

m = {"key": null}
print(m["key"] ?? "fallback")   // prints: fallback
print(null ?? "default")         // prints: default

Why

This aligns ?? with developer expectations from JS/Kotlin/Swift where ?? is a null-coalescing operator. The old error-only behavior is now available via the new catch operator (see below).

Migration

If you relied on null passing through ??, use catch instead:

// If you want error-only catching (old behavior), use catch:
result = maybe_error_value catch "default"

New: catch Operator

The catch operator provides the old ?? error-only behavior as an inline expression. It catches errors but lets null values pass through.

count = parse_int(input_str) catch 0
data = parse_json(raw) catch {}

This is distinct from the catch: block syntax. The operator form is an inline expression; catch: is a block attached to statements.

Breaking Change: Strict + Concatenation

What Changed

The + operator no longer implicitly converts int, float, or bool to strings. Both operands must be the same type (with the exception that errors behave like strings for concatenation).

Before (v0.8)

print("count: " + 5)       // printed: count: 5
print("pi: " + 3.14)       // printed: pi: 3.14
print("flag: " + true)     // printed: flag: true

err = error("oops")
print(err + 123)            // printed: oops123

After (v0.9)

print("count: " + 5)       // error: RAD30002
print("pi: " + 3.14)       // error: RAD30002
print("flag: " + true)     // error: RAD30002

err = error("oops")
print(err + 123)            // error: RAD30002

Why

Implicit coercion was asymmetric ("hi" + 5 worked, but 5 + "hi" errored) and used a different conversion path from string interpolation. Making + strict catches type bugs at the point of error rather than silently producing unexpected strings.

Migration

Use interpolation (preferred) or str() for explicit conversion:

// Interpolation - handles any type, recommended
print("count: {5}")
print("pi: {3.14}")

// Explicit conversion with str()
print("count: " + str(5))
print("pi: " + str(3.14))

Note: errors still behave like strings for concatenation ("s" + error("e"), error("e") + "s", and error("a") + error("b") all work).

Breaking Change: parse_epoch Unit Names Shortened

What Changed

The unit parameter for parse_epoch now uses short names to match the rest of the API (now() epoch keys, convert_duration/parse_duration enums).

Old Syntax (No Longer Works)

time = parse_epoch(1712345678000, unit="milliseconds")
time = parse_epoch(1712345678000000, unit="microseconds")
time = parse_epoch(1712345678000000000, unit="nanoseconds")

New Syntax

time = parse_epoch(1712345678000, unit="millis")
time = parse_epoch(1712345678000000, unit="micros")
time = parse_epoch(1712345678000000000, unit="nanos")

Migration

Replace "milliseconds" with "millis", "microseconds" with "micros", and "nanoseconds" with "nanos" in any parse_epoch calls.

Error Messages

If you run a script that still uses request or display, you'll see:

error[RAD40008]: 'request' blocks have been removed. Use 'rad' instead.
  --> script.rad:1:1
   |
 1 | request "https://api.example.com/users":
   | ^^
   |
   = help: See migration guide: https://amterp.github.io/rad/migrations/v0.9/
   = info: rad explain RAD40008

If you run a script that still uses get_stash_dir, you'll see a helpful error:

error: Cannot invoke unknown function: get_stash_dir

  hint: get_stash_dir was renamed to get_stash_path.
  See: https://amterp.github.io/rad/migrations/v0.9/

If you use the old long-form unit names with parse_epoch, you'll see:

error: parse_epoch unit "milliseconds" is no longer valid

  help: Unit names were shortened in v0.9. Use "millis" instead.
  See: https://amterp.github.io/rad/migrations/v0.9/

Behavior Change: Invocation Logging Enabled by Default

What Changed

Invocation logging is now enabled by default. Each time you run a Rad script, Rad logs basic metadata (script path, timestamp, version, duration) to ~/.rad/logs/invocations.jsonl. Previously, this was opt-in.

Arguments are not logged by default - only the metadata listed above.

Why

Invocation logs are most useful when they're already there - for example, rad check --from-logs can bulk-check your recently-used scripts after an upgrade. Enabling logging by default means these tools work out of the box.

How to Opt Out

If you prefer to disable logging, set enabled = false in ~/.rad/config.toml:

[invocation_logging]
enabled = false

For more details on all available settings, see the Configuration guide.

Breaking Change: trim_prefix / trim_suffix Behavior Changed

What Changed

trim_prefix and trim_suffix now remove a literal prefix/suffix string, rather than stripping a character set. The old character-set stripping behavior is available via trim_left and trim_right.

Before (v0.8) - Character-Set Stripping

In v0.8, trim_prefix removed all leading characters that appeared in the given string, similar to Go's strings.TrimLeft. It treated the argument as a set of characters, not a literal string:

trim_prefix("aaabbb", "a")    // -> "bbb"  (stripped all leading 'a' chars)
trim_suffix("bbbccc", "c")    // -> "bbb"  (stripped all trailing 'c' chars)
trim_prefix("abcfoo", "abc")  // -> "foo"  (stripped all leading 'a', 'b', or 'c' chars)
trim_prefix("cabfoo", "abc")  // -> "foo"  (same result - order didn't matter, it was a char set)

After (v0.9) - Literal Prefix/Suffix Removal

Now, trim_prefix removes the argument as a literal prefix. If the string doesn't start with that exact prefix, it's returned unchanged:

trim_prefix("aaabbb", "a")    // -> "aabbb"  (removed one literal "a" prefix)
trim_suffix("bbbccc", "c")    // -> "bbbcc"  (removed one literal "c" suffix)
trim_prefix("abcfoo", "abc")  // -> "foo"    (removed literal "abc" prefix)
trim_prefix("cabfoo", "abc")  // -> "cabfoo" (no match - "cabfoo" doesn't start with "abc")

The old character-set behavior is now available as trim_left and trim_right:

trim_left("aaabbb", "a")      // -> "bbb"  (old trim_prefix behavior)
trim_right("bbbccc", "c")     // -> "bbb"  (old trim_suffix behavior)

Migration

If you were using trim_prefix or trim_suffix to strip characters from a set, switch to trim_left / trim_right respectively.