Migrating off deprecated syntax

Deprecated syntax keeps working. It always leaves in three stages: deprecated, migrated, removed - never straight to removed.

ecko fix --migrate --check src/    # report what needs it, write nothing
ecko fix --migrate src/            # rewrite in place

The three stages

1. Deprecated. The old form still parses and runs. ecko check warns on it, and ecko fmt rewrites it to the current form as part of an ordinary format pass. 2. Migrate. Run ecko fix --migrate (or just ecko fmt) before the version that removes the old form ships. Both do the same rewrite; --migrate touches only the deprecated constructs, so the diff is the migration and nothing else - useful on a project that is not kept formatted with fmt. 3. Removed. A later version deletes parser support for the old form. Unmigrated code stops parsing at that point, not before - there is no silent behaviour change in between.

Today's deprecations

deprecatedcurrent formidentical behaviour
const x = 1let x = 1yes - const is only ever a spelling of let
`\x\x * 2`fn(x) x * 2yes
x: Option<Int>x: Int?yes - both mean an Int or null
ai[T] 5 "..."ai[T] "..." votes 5yes

const to let

const limit = 100   # deprecated: identical to `let`
print(limit)

const was an early alias for let. There is no behavioural difference to reason about - the migration is a rename:

let limit = 100
print(limit)

Pipe lambdas to fn(params) body

double = |x| x * 2   # deprecated pipe-lambda form
print(double(21))

fn(params) body is the canonical anonymous form and is what ecko fmt produces from the pipe form:

double = fn(x) x * 2
print(double(21))

See Functions & lambdas for the full lambda syntax.

Option<T> to T?

fn port(p: Option<Int> = null) = p ?? 8080   # deprecated spelling

T? is the nullable annotation - a T or null - and Option<T> was an older spelling of exactly that:

fn port(p: Int? = null) = p ?? 8080
print(port())

The vote count goes after the prompt

type Mood = Up | Down
m = ai[Mood] 5 "classify this"   # deprecated: a bare number before the prompt

votes 5 after the prompt says what the number is:

type Mood = Up | Down
m = ai[Mood] "classify this" votes 5
print(m == Up)  # true in mock mode

Changed in 0.64: a misspelt assignment is an error

Inside a loop or branch, totl = total + n used to declare a new local and leave total unchanged. When the new name is never read and is one slip from a mut in scope, the program no longer starts (likely-typo), and the error names the mut it probably meant:

mut total = 0
for n in [1, 2] {
    totl = total + n   # error: did you mean `total = ...`?
}

Either you meant the mut the error names, or you keep the new local by reading it or prefixing it with _.

Changed in 0.63: a non-exhaustive match is an error

A match over a declared type that leaves a variant without an arm, and has no _, now stops ecko check and keeps the program from starting. It used to be a warning:

type Status = Active | Suspended | Closed
fn label(s) = match s {
    Active => "on"
    Suspended => "paused"
}
# error[non-exhaustive-match]: match over Status does not cover: Closed

Add the missing arms, or a _ => arm for the rest. A guarded arm does not cover its variant. See Pattern matching.

Removed in 0.62: the built-in demo types

Sentiment, Classification and Entity, with their variants (Positive, Spam, Person, ...), are no longer built in. Add the declaration to any program that used one without declaring it - the error names it:

type Sentiment = Positive | Negative | Neutral
print(ai[Sentiment] "classify: great product")

Removed in 0.61: the old standard-library surface and setting names

Six modules and some members of three more were deprecated in 0.59 and are gone in 0.61. Importing or calling one is now an error - from ecko check before the program runs, and at run time where the check cannot see the call - and the error names the replacement:

removeduse instead
std.humanizethe humanize package - same functions, same answers
std.clithe cli package 1.0 - same spec, parse and help
std.debugtype_of, time.monotonic(), fmt.inspect
std.serial, std.db, std.ragnothing in the standard library; embed, cosine and ai remain
term.bold, term.red, ... (44 string builders)the same names in the tui package, 0.39 or later
fmt.pad_left, pad_right, repeat, truncatestr.pad_start, str.pad_end, str.repeat, s[..n]
image.freenothing - an image is a value; delete the call

For the two packages, the switch is the install line and the import:

ecko get github.com/ecko-lang/humanize
ecko get github.com/ecko-lang/cli
import humanize    # was: import std.humanize
import cli         # was: import std.cli

For retrieval without std.db or std.rag, see Embeddings & RAG: an index is a list of embeddings, and a search is cosine and a sort.

The setting names that changed in 0.58 - ECKO_API_KEY, ECKO_TRACE, ECKO_MAX_* and the rest in the table

  • are no longer read either. One that is still set prints which name replaced it, and the program runs as if it were not set.

Changed in 0.60: return types are checked

fn f(x) -> Int used to be accepted and ignored. Every value the function returns, its last value and each return, must now fit the type, and a mismatch is a catchable error:

error: `label` is declared to return String, but returned int

Correct the annotation, or remove it. A function that can end without a value needs -> T?. See Functions & lambdas.

Changed in 0.60: each for iteration has its own loop variable

A closure created in a loop used to see the loop's last value:

mut fs = []
for i in [1, 2, 3] {
    fs = push(fs, fn() i * 10)
}
print(map(fs, fn(g) g()))  # [10, 20, 30]; was [30, 30, 30]

Code that relied on a closure seeing the loop's final value should capture it explicitly: assign it to a variable declared before the loop and read that instead.

Changed in 0.59

Two changes in 0.59 were not deprecations:

  • An image is a value, { format, width, height, bytes }, not a handle. std.image takes it as it took the handle, so most code is unchanged; pass the image itself to ai ... on, and delete image.free calls.
  • cost(model, in, out) is gone with its stale price table. Write cost(in_tokens, out_tokens, in_per_1m, out_per_1m) with current prices.

Changed in 0.58: every duration is milliseconds

Five APIs took or returned seconds; every other time value in Ecko was already milliseconds. They keep their names and now use milliseconds too:

APIbefore 0.58now
sleep(x)seconds, Int or Floatmilliseconds, Int
time.monotonic()Float secondsInt milliseconds (time.monotonic_ns() for nanoseconds)
debug.timer() / debug.elapsed(t)Float secondsInt milliseconds
humanize.duration(n)secondsmilliseconds
http.get/post/... timeout:seconds, Int or Floatmilliseconds, a positive Int

A Float is refused where milliseconds are expected, so sleep(0.5) and timeout: 1.5 fail with the millisecond spelling in the message. A whole number cannot tell you which unit it meant - sleep(2) now waits two milliseconds - so when you upgrade from 0.57 or earlier, run the ms codemod once over code written for seconds:

ecko fix --migrate --only=ms src/

It scales arguments up (sleep(2) -> sleep(2000), sleep(n) -> sleep(int((n) * 1000))) and readings back down (time.monotonic() -> (time.monotonic() / 1000)), so code that worked in seconds keeps working in seconds. Run it once.

Changed in 0.58: options are named arguments only

A built-in's options used to arrive either as named arguments or as a map. A map in their place is now refused, with the named spelling in the error:

proc.run("ffmpeg", args, { timeout_ms: 5000 })   # was accepted
proc.run("ffmpeg", args, timeout_ms: 5000)       # now

Change f(x, { name: value }) to f(x, name: value). Maps that are data, such as http.response headers, are unchanged.

Changed in 0.58: the shell wins over ecko.json

An entry in ecko.json's environment block now applies only to a variable that is not already set. It used to override what you had exported. A project that relied on the block overriding the shell should have its users unset the variable, or set it in the program.

Changed in 0.57: Ecko's flags go before the file

Everything after the file is the program's, verbatim, so ecko app.ecko --model x hands --model x to the program. Move --provider, --model, --cache and --trace before the file:

ecko --provider openrouter report.ecko

Removed in 0.57: --key

A key on the command line ends up in shell history and in ps output. Set ECKO_AI_API_KEY in the environment instead.

Changed in 0.57: \u{...} is a Unicode escape

"\u{1}" used to be the three characters \u1. It is now one character, U+0001, and "caf\u{e9}" is café. A string that meant the three characters writes "\\u1".

Changed in 0.57: a number in an interpolation hole is refused

"^[A-Z]{3}$" is the string ^[A-Z]3$, a regex that never matches. A hole holding only a number is now an error (literal-interpolation). Write the regex or template as a raw string, r"^[A-Z]{3}$", or escape the brace, "\{3}".

Changed in 0.56: encoding a function to JSON is an error

json.encode, json.write, http.json and an HTTP json: body used to write a function, task, channel, stream, module or range as its printed form. They now throw. Encode the data, not the value that produces it - for a range, list(r).

Changed in 0.55: every runtime error is a {kind, message} map

A programmer mistake - an index out of bounds, a missing field, a wrong type or arity - used to reach catch (e) as a bare string. It is now {kind: "bug", message}:

try { [1, 2][5] } catch (e) {
    print(e.kind)  # bug
}

Where a handler used a caught mistake as text ("{e}", contains(e, "...")), read e.message. A get(e, "kind") == null test for "this was a programmer mistake" becomes get(e, "kind") == "bug".

Changed in 0.55: a bare x = v is immutable

A bare x = v used to declare a mutable binding; it now declares an immutable one, exactly like let. Reassigning it, or writing through it (m.key = v, xs[0] = v), needs the binding declared mut, and ecko check refuses the program before it starts (immutable-reassign). The mut codemod adds mut where each reassigned binding is declared:

ecko fix --migrate --only=mut src/

A reassigned parameter or loop variable is not rewritten (ecko check names it): start the body with mut n = n. See Variables & mutability.

Changed after 0.50: / divides, // floors

/ on two ints used to drop the fraction: 7 / 2 was 3. It now always divides, so 7 / 2 is 3.5 and 6 / 3 is 2.0. Write // where you meant a whole number:

mid = (lo + hi) // 2          # an index
pages = (count + 9) // 10     # a count
avg = total / n               # a mean keeps its fraction

A missed one usually shows up at the next line that needs an int - needs an integer, or an index that is a float. // floors, so for negative operands it rounds down where the old / rounded toward zero (-7 // 2 is -4). % floors with it: -7 % 2 is 1 (it was -1), which is what clock and calendar arithmetic wants; for the old remainder write a - b * int(a / b). No codemod can make this change for you: whether a / wanted a whole number depends on the values, not on anything in the source.

Changed after 0.50: + joins a string only to a string

"count: " + 5 used to give "count: 5", and 5 + " items" gave "5 items": + quietly formatted whatever sat beside a string into it. So a number that arrived as text, "5" + 1, gave "51" where the program meant 6. It is now an error, and the message says what to write instead:

"count: " + 5          # error: Can't add string and int
"count: {n}"           # interpolate
"count: " + string(5)  # or convert

A caught error is the case most code hits, because it can be a {kind} map: print("failed: " + e) becomes print("failed: {e}").

The strplus codemod makes the conversion explicit for you. Wherever one side of a + is visibly a string, it wraps the other side in string(...), which keeps the meaning whatever the runtime types are:

ecko fix --migrate --only=strplus src/

It cannot see a + whose sides are both variables (label + count) or one inside an interpolation; the first run of the program names those lines.

Changed in 0.24: two errors that used to read differently

Nothing was removed. Two things that a catch block can see changed shape, so code that reads a caught error rather than just re-raising it needs a look.

Eight modules now throw a map where they threw a sentence. db, image, config, defaults, encoding, zlib, time and llm had no structured errors at all, so get(e, "kind") came back null for a corrupt file and a bad argument alike, and nothing could tell them apart. Their operational failures now carry a kind like the rest of the standard library.

# Before: the caught value was a string, so this worked.
try { zlib.gunzip(data) } catch (e) { print("failed: " + e) }

# After: read the message field.
try { zlib.gunzip(data) } catch (e) { print("failed: {e.message}") }

print(e) and error(e) are unchanged, and a caller mistake - passing an int where bytes belong - still arrives as prose, because that is a bug to fix rather than a condition to dispatch on.

A file refused by the allocation cap is kind: "limit", not kind: "fs". Nothing about the file failed. A ceiling you configured refused it, and the payload now names which ceiling and which variable raises it:

try {
    data = fs.read(path)
} catch (e) {
    match get(e, "kind") {
        "limit" => print("too big: {e.limit} byte cap, raise {e.env}")
        "fs"    => print("could not read it: {e.message}")
        _       => error(e)
    }
}

A match arm that dispatched on "fs" for a too-large file wants "limit". Every other fs failure is unchanged.

Removed in 0.23: socket and pipe verbs

std.net and std.proc had their own reading and writing verbs. They are gone, replaced by the std.io verbs that read every stream, so socket code and file code now look the same.

wasnow
net.send(c, data)io.write(c, data)
net.recv(c, n)io.read(c, n)
net.recv_text(c, n)io.read_text(c, n)
net.recv_exact(c, n)io.read_exact(c, n)
net.recv_until(c, delim)io.read_until(c, delim)
net.close(c)io.close(c)
proc.write(p, data)io.write(proc.stdin(p), data)
proc.read_line(p, ms)io.read_line(proc.stdout(p)), plus io.timeout
proc.close_stdin(p)io.close(proc.stdin(p))

What changed beyond the spelling

Most of the table above is a rename: same behavior, new spelling. Three of the replacements are not:

  • A clean end of stream used to be an error; now it is null. net.recv_exact and net.recv_until were built on Read::read_exact, which raises UnexpectedEof even when the peer closed cleanly before sending anything. io.read_exact and io.read_until return null at a clean end instead, and raise only when the end arrives mid-answer - short of the count, or with bytes read and no delimiter (see std.io). Code that indexed the old call's result right away - the shape every shipped DB and mail client used - needs a null check added, not just the name swapped: without one, a peer that closes the connection turns a clear net: error into Can't index null with int somewhere inside a protocol parser, which is a much harder failure to trace back.
  • proc.read_line decoded with replacement characters; io.read_line raises. The old call used from_utf8_lossy, so bytes that were not valid UTF-8 quietly became \u{FFFD}. io.read_line raises instead, the same as every other text read on a stream.
  • net.send and net.starttls returned null; the replacements return a value. io.write(c, data) returns the byte count net.send discarded. net.starttls now returns the stream it upgraded, so it chains with the rest of std.io instead of the caller having to hold c separately.

A rewrite that only changes the spelling still runs. It just handles a clean disconnect, bad bytes, and the write/upgrade result differently than the code was written to expect.

There is still no codemod for this one. A mechanical rewrite would have to guess: proc.read_line(p, 5000) becomes two calls, and its 5000 ms meant "return null after five seconds" where io.timeout means "raise an error after five seconds". That difference is the reason the verbs changed, so it wants a person reading the call rather than a regex.

ecko check reports a removed verb wherever the call is written as net.recv(...), at the top level or inside a function body, and the program does not start until it is gone:

'net' has no member 'recv': std.net.recv was removed in 0.23 - use io.read(c, n)

A call the checker cannot see - through a module held in a variable, say - carries the same hint in the error the program raises when that line runs:

Module has no export 'recv': std.net.recv was removed in 0.23 - use io.read(c, n)

import std.io is the other half - the module that used to be optional is now where the verbs live.

One-time codemods are different from --migrate

Five rewrites belong to specific breaking releases and are not run by plain --migrate, because they are reinterpretations rather than canonicalisations - running one twice on already-migrated code would corrupt it (see ecko fix for why):

ecko fix --migrate --only=bytes src/     # *_decode -> *_decode_text
ecko fix --migrate --only=exports src/   # the old `export { ... }` block to inline `export`
ecko fix --migrate --only=strplus src/   # "n=" + 5 -> "n=" + string(5)
ecko fix --migrate --only=mut src/       # add `mut` where a binding is reassigned (0.55)
ecko fix --migrate --only=ms src/        # seconds -> milliseconds (0.58)

Run each once, on code you know has not already been through it.

Checking without changing anything

ecko fix --migrate --check src/ reports which files need a rewrite and exits non-zero if any do, without touching a byte - the shape you want in CI, ahead of the version that removes the old form:

ecko fix --migrate --check src/

See ecko check for the warnings that surface deprecated syntax during ordinary development, and ecko fmt for the formatter that rewrites it as a side effect of formatting.