Skip to content

Deed

A contract-first language where a function signature is a promise the compiler checks. Built for code that machines write and humans review.

Status: it runs. The compiler lexes, parses, resolves names, type checks and checks effect rows, and a tree walking interpreter executes test blocks and main with contracts enforced at runtime. Programs get their authority from a System capability handed to main, and a Dir narrows to a subdirectory and cannot be walked back out of. There is no code generation, and what a run costs without it has been measured rather than guessed at: design/01-principles.md has the table and the reason a compiler is not the next thing. Criticism of the design is still the most useful contribution. See issue #228 for where this is going.

Getting it

Every release carries a deed binary for Linux, macOS and Windows. Take the one for your machine from releases, unpack it, and run it. There is nothing else to install: the compiler, the formatter, the test runner and the language server are all in that one file.

$ tar xzf deed-v0.1.0-x86_64-unknown-linux-gnu.tar.gz
$ ./deed-v0.1.0-x86_64-unknown-linux-gnu/deed --version
deed 0.1.0

From source instead, which needs Rust 1.85 or newer:

$ cargo install --path crates/deed-cli

What the version promises. 0.x says the language can still move. A file that compiles against one release may not compile against the next, and when that happens the release notes say what changed and deed fix applies whatever part of it is mechanical. Two things are steadier than the syntax, because tools are built on them: a diagnostic code is never reused, so DEED4009 means today what it meant when it was assigned, and --format json is an output something else can read.

$ deed run examples/config.deed --dir examples
found it
`..` would leave the directory, and there is no way out of a `Dir`
`../Cargo.toml` is not a single name, and a `Dir` only takes one at a time
`/etc/passwd` is not a single name, and a `Dir` only takes one at a time
`nowhere` is not there
used the fallback
$ deed test examples/
examples/counter.deed
  ok    bumping twice adds twice
  ...
examples/transfer.deed
  ok    moves the money and conserves the total
  ok    refuses to overdraw and leaves the ledger alone
  ok    refuses a currency mismatch and leaves the ledger alone

95 passed, 0 failed

That number used to be 102. Seven of those tests are in std/table now, because the table library stopped living under examples/ and a module that ships with the compiler is context rather than subject, so the corpus does not run its tests any more. Nothing was deleted: the modules that ship carry thirty-seven tests of their own, and crates/deed-driver/tests/shipped.rs runs every one of them on every commit.

$ deed check examples/transfer.deed --obligations
obligations: 7 proven, 0 tested, 6 guarded
  guarded  examples/transfer.deed:94:5  transfer ensures ok
  ...
  proven   examples/transfer.deed:202:76  Positive

For an editor, see editors. VS Code gets syntax highlighting from three JSON files and no code. Helix and Neovim get the language server from a few lines of configuration and no plugin. Neither gets both yet, and the table there says why.

The idea

Most of the code being written today is not typed out by a person. That changes which costs matter. Producing a line of code is close to free. Reading it, trusting it, and finding out three weeks later that it was subtly wrong are not.

Every language we use was shaped by the opposite assumption. They optimize for the comfort of someone typing character by character: implicit behaviour, clever shorthand, five ways to express the same thing. All of that is now working against us.

Deed makes a different trade. Signatures carry the entire contract, bodies are checked against it, and nothing in the language lets a function reach outside what its signature admits to.

fn transfer(from: AccountId, to: AccountId, amount: Amount)
    -> Result<Receipt, TransferError>
  where
    from != to,
  uses
    Ledger.balance,
    Ledger.post,
    Audit.append,
  ensures
    ok  => result.from == from,
    ok  => result.amount == amount,
    ok  => Ledger.balance(from).units == old(Ledger.balance(from).units) - amount.units,
    ok  => Ledger.balance(to).units == old(Ledger.balance(to).units) + amount.units,
    ok  => Ledger.total() == old(Ledger.total()),
    err => unchanged(Ledger),
{
    ...
}

This is not an illustration. It is the top of examples/transfer.deed, which the compiler lexes, parses, resolves, type checks and effect checks on every commit.

Three things follow from that block, and they are the whole pitch:

You review the signature, not the body. It fits on one screen and it is complete. Whoever wrote the body had nothing else to satisfy.

The body needs no other context. No global state, no implicit conversions, no inheritance, no exceptions thrown from four frames down. Everything that can affect this function is written above the brace.

The function cannot reach the network. Not by accident and not on purpose. It did not ask for that capability in uses, so it does not have it, and it holds no value that would let it name one. Sandboxing stops being a container and becomes a type.

Why this is a language and not a library

A library can offer contracts. It cannot stop you from ignoring them, it cannot see the whole call graph, and it cannot make the absence of an effect mean anything. Contracts, effects and capabilities are only useful if they are total, and total means the compiler enforces them.

The longer version, including the cost model this is all based on, is in design/00-motivation.md.

Design documents

Document What it covers
00-motivation.md The cost model, and why a library cannot do this
01-principles.md The constraints we are willing to be held to
02-syntax.md Contract blocks, types, errors, modules
03-effects.md Effect declarations, propagation, handlers
04-capabilities.md Authority, how it enters a program, and why

Read them in order. Each one leans on the one before it.

What is built

Crate Does
deed-diagnostics Spans, source maps, and structured diagnostics with machine-applicable fixes
deed-lexer Source text to tokens
deed-ast The syntax tree
deed-parser Tokens to a tree, with recovery
deed-resolve Every name bound to a declaration, including across module boundaries
deed-typeck Every expression given a type, including types from other modules
deed-effects Every effect row checked against what the body does
deed-interp Runs test blocks, property tests and main, with contracts enforced
deed-fmt The one canonical form, with no options for the output
deed-lsp A language server: diagnostics, hover, go to definition, references, rename, completion, signature help, quick fixes, an outline, workspace search and formatting
deed-driver Runs all of the above, in one place, so nothing drifts
deed-cli The deed binary: check, test, run, fmt, fix and lsp

deed lsp is a language server, and most of it is plumbing over things that already existed: the compiler produces structured diagnostics with spans, Types::type_of can say what an expression turned out to be, Resolutions can say where a name was declared, and the formatter has one canonical answer with no options. It publishes diagnostics as you type, says the type of whatever is under the cursor, jumps to a declaration in whichever file declares it, lists every use of a name across the workspace, renames one everywhere it is written, offers what could be written where the cursor is, offers the patch a diagnostic is already carrying, says what the file declares, and formats a file.

Signature help is the one that is most this language's own. Everywhere else the contract is on the screen already, because it is written on the declaration. At a call site it is not: transfer(a, b, amount) says nothing about what the call performs or what it needs to be true first, and that is exactly where somebody is deciding whether they can afford it. So the label carries the row as well as the types, and shout(Console, String) uses Io.write -> () is the whole signature rather than the part that fits an editor's idea of one.

It is read off the text rather than the tree, for the same reason completion is. add( with nothing after it does not parse, and that is the moment the question gets asked. Nesting is a stack rather than a counter, so the comma in add(add(1, 2), 3) belongs to the inner call and the ones in total([1, 2, 3], 4) belong to the list.

That last one was data with nowhere to go. A diagnostic carries the edit that resolves it where the repair is unambiguous, and the only thing that could reach one was deed fix on a command line, which applies the certain ones and skips the guesses. So the guesses were reachable by nothing at all, and "cannot find lenght, did you mean length" knew the answer and made the reader type it. They are quick fixes now, and Applicability carries straight over: a machine-applicable fix is the one deed fix would apply without being asked, so it is the preferred action, and a guess is offered and never preferred. The editor already had a word for the distinction and the compiler already had the distinction.

Completion is the only one that is a different shape. Everything else answers a question about a name that already exists, and a document does not parse while somebody is typing into it, so it answers a narrower question instead: what is in scope here. After a . that is the fields of whatever is to the left; inside use a/b.{ } it is what that module exports, which is the one place a name has to match another file exactly; anywhere else it is what the file declared, what it imported, and the prelude. Nothing it offers inserts more than a name. No snippets, no argument placeholders, no auto-import, because each of those is a decision about what somebody meant.

The outline is the one answer that does not go through a check. Everything else this server says is about a name that resolved; an outline is about what is written, and half a declaration is still worth drawing while somebody is typing the rest of it, so it reads the parse tree of the one file and nothing else. What belongs to a declaration is nested under it, because a variant belongs to its choice and a handler operation belongs to its handler and a list that said otherwise would be a worse version of scrolling. The protocol has fewer kinds than this language has declarations, so a type and an effect share one: both are a named thing declared by its shape, and picking a different icon for one of them would be picking it for how it looks rather than for what it is.

Rename and find references are the same walk with two answers, which is the point of them sharing one. A rename that edited the declaration and left the use line that brought the name into another file would fix one file and break another, and that is worse than not having rename at all. What it deliberately does not decide is whether the new name is a good one: a collision with something already in scope is something the checker has a diagnostic for, and answering it twice is how the two answers come to disagree. It does refuse a name the language cannot hold, and it asks the lexer rather than holding a second definition of what an identifier is.

It checks a document together with every other .deed file in the folders the editor said it has open, and an open file's text comes from the buffer rather than from disk, so removing an export in one file puts a squiggle on the import in another before either is saved. The set of files is the workspace rather than a guess: initialize carries it, and taking what the editor says is the same answer deed check src/ gives when a person says which directory they mean. An editor that names no folder gets the single file behaviour. That was the only behaviour until recently, and it meant every file with a use in it had a red line under the import, which is exactly the failure a server is not allowed to have.

It rechecks the whole workspace on every keystroke and on every hover. That used to be followed by "and nothing has measured it", which is now not true: cargo run -p deed-driver --example edit_loop --release says the recheck is linear at about 70 microseconds per file, so 512 files cost about 38ms per keystroke, inside P9's 100ms budget, and a few thousand files is where it leaves. It also says 99% of that work is spent on files that did not change, which is what a cache would take off. So the cache is worth writing when the size arrives and is not worth writing now, and that is a conclusion with a number behind it rather than a feeling. design/01-principles.md has the table.

It has no dependencies either. The protocol is a Content-Length header, a blank line and a handful of object shapes, so the JSON reader and the framing are written out. Two parts are worth reading. Positions: the protocol counts UTF-16 code units and the compiler counts bytes, which agree for ASCII and stop agreeing the moment somebody writes a comment in Turkish. And URIs: a space arrives as %20, a Windows drive as /c%3A/, and a Turkish letter as two escapes that are bytes rather than characters. Getting either wrong is silent.

The examples are transfer.deed, counter.deed, hello.deed, config.deed, todo.deed, journal.deed, logs.deed, proven.deed, closures.deed, diverge.deed, strings.deed, lists.deed, generics.deed, generic_types.deed, using_list.deed, and the three that see each other: names.deed, sink.deed and greeting.deed. All are checked by every pass on every commit, hello.deed, config.deed, todo.deed, journal.deed and logs.deed have a main, and the rest run their own tests.

todo.deed is the one written to find out what is missing rather than to show what is there. It reads a list of tasks out of a directory it was handed, adds one or marks one done if it was given anything on the command line, counts them, and prints the ones that are not done. That is the smallest thing anybody would call a program and it was not writable at all a week ago. It found four things. Three of its functions were the same function: start at zero, look at each element, stop when the index runs out, and declare Diverge for the privilege. An accumulator had to be threaded through as a parameter, because handler state is the only mutable thing in the language and reaching for a handler to collect strings would be using an effect to avoid a loop. Those two are what decided what a loop looks like here, and the three walks are for loops now with nothing declared. The file format was x|title rather than [x] title because splitting was all there was, so the data got bent to fit the tool. And the first run printed its own output backwards over itself, because splitting on \n leaves a carriage return on every line of a file written on Windows and there was nothing that trimmed one off. The last two are what trim is for, and the file format is [x] title now. None of those were obvious from inside the compiler.

Marking a task done is the thing that file said for months it could not do, and it turned out to be one map over the tasks with one of them replaced. What took the time was being able to write that map once: generic functions, generic types and a row variable all had to exist first, and once they did the feature was four lines. It found one more thing on the way out, a small one. state was a keyword, so with state = .. did not parse, and state means something in exactly one position where the only alternative is fn. Reserving a word that common for one position is a cost nobody had paid until a program wanted the word, so it is a name again and the parser recognises it where it matters.

The last thing on its list was two tasks with the same title, where done buy milk finishes both because a title is being used as a name and it is not one. done 2 finishes the second task now, and no part of the language changed to allow it, which is what makes it worth reporting. A position is a fact about where an element is and a callback is only ever handed the element, so map and filter stop being enough the moment position matters and the walk goes back to a counter in a record written out by hand. There were three of those in that file when this was written, one of them replacing a map(filter(..)) that used to be a single line. for task at here in tasks came out of exactly that and took all three back to one line each.

And nothing bounds the number. done 9 on a file with two tasks walks both, marks neither and hands back the list it started with, so whether it meant anything has to be asked again by a second function comparing it against the length. That is the shape an index has when the type system cannot hold "in range", and it is the argument for the refinement tier made by a program rather than by a design document. What to do about it is the next question rather than this one.

for is a fold with syntax rather than a loop with a variable in it:

let total = for n in numbers with sum = 0 {
    sum + n
}

The block's value is the accumulator for the next turn, and sum is a fresh binding every time rather than something assigned to, so iteration exists and a handler's state is still the only mutable thing in the language. The second reason for that shape is Diverge: there is no termination proving, so a function that can reach itself has to declare it may not return, and without a loop every walk over a list is recursion. A row that almost every function carries the same entry in has stopped saying anything. A for walks a list that is already there, so it stops, so it declares nothing.

It also says where in the list it is, which came out of todo.deed for the third time. for task at here in tasks with kept = [] binds the position, zero-based like the at that indexes a list, and it is known to be a real one: not negative, and below the length of what is being walked. Before it, three walks in that file carried a counter in a record so that something could ride alongside the answer, and every branch had to remember to bump it. The reason this is in the language rather than in the library is that the library cannot have it otherwise: everything in std/list is written with a for, so map cannot hand a callback something the walk never knew. With it, map_at is four lines of Deed and no part of the language grew a second map. at is still an ordinary name, since the only thing that can follow a for binder is at or in.

journal.deed is the half todo.deed could not write. Io could read a file and open a directory and there was no operation that wrote one, so Dir was a read capability wearing a more general name. Io.save is that operation, and the thing worth looking at is what stops it writing anywhere else. Two separate things do. The row: a function that does not declare uses Io.save cannot write, whatever it is holding, so reading and writing are different authorities over the same capability and which one a caller is handing over is written in the signature. The capability: Io.save takes the Dir it writes into and there is no way to construct one, and the name goes through the same check reading goes through, so .., an absolute path, a separator and a symlink pointing out are refused for writing exactly as they are for reading. Not a second implementation that agrees today.

Io.remove is the case where a second kind of Dir was most tempting. Reading, listing and writing all leave what was there; deleting does not, and a program that writes the wrong bytes can be put back from what it overwrote while one that deletes the wrong file cannot be put back from anything. It got the same answer as writing did, and for the same reason: uses Io.remove cannot be reached from uses Io.save, holding a Dir says nothing about which of the four operations a function may do, and nothing new had to be built for it. That is three tests of the same claim now, and the third was the one meant to break it. todo.deed can say clear, and the function that does the deleting declares uses Io.remove and nothing else, so it cannot read the file it is about to delete.

Io.make is the fourth test and the one that looks like it breaks the rule that authority only ever shrinks. It hands back a Dir, and a Dir is authority, so this document said for a while that creating a directory was authority being made rather than narrowed. That was wrong. A Dir reaches everything under its root and which of those paths happen to exist is not part of what it grants, which is why Io.save writing a file that was not there is not authority creation either. What Io.make returns is rooted inside what it was given, so it reaches strictly less, and there is a test that makes a directory and then fails to climb out of the result. Nothing may already be at the name: "I made it" and "it was already there" are different answers.

Io.epoch is the same split about something other than authority. Io.now counts calls rather than reading a wall clock, because P8 says the default is deterministic and a real clock would make every run different, which is the right answer for a test and the wrong one for a program that needs the actual time. So the machine's clock is a second entry in the row on the same Clock. uses Io.now cannot reach it and it cannot reach uses Io.now, and what that buys is not narrower authority: a signature saying uses Io.epoch is a function whose output can change between two runs of the same program, and that is now something you can see without reading the body.

proven.deed is the one that argues with itself. Every function in it either proves its postcondition or explains, in a comment, why the checker cannot, and the file is written so that the two halves sit next to each other. The Proven tier used to hold constant expressions and nothing else, which made a refinement in real code a runtime check with ceremony around it. It now reasons about intervals, about the difference between two names, and about what a callee promised: a where clause, a refined parameter type, an if condition, a guard that returns, low < high, and ensures ok => result == n at a call site are all facts the rest of the body can use.

How long something is is one of them now. length(items) used to come back as a range and nothing else, which meant it could not be one side of a difference, so index < length(items) was invisible to the machinery that existed for low < high. A length is a term keyed on the thing being measured, and the rest falls out of the two rules that were already there: the default is zero and up, because there is no list with fewer than no things in it, and past if length(items) <= 0 { return err(..) } one less than the length is an index that is really there. Two lists are two terms, and length(f(items)) is not a term at all, since two calls could hand back two different lists.

That was prompted by todo.deed, which marks a task by position now and could not say no to done 9 before running. What made the fact worth having is the other half of the same change: a where clause is read at the call site now. It was not before. A precondition was a fact for the callee's body and a check inside the callee at runtime, and nothing ever looked at it from where the call was written, so halve(0 - 5) against where n >= 0 passed the checker in silence. The design doc had described the call-site check for months. Now a call that plainly breaks a clause is an error where the call is, a caller that can show the clause holds is Proven, and a caller that cannot is Guarded with the runtime check still standing. proven.deed went from four proven obligations to forty-five that way, and most of them are calls rather than values.

What crosses into a clause is the caller's facts said in the callee's parameter names: each argument's range, how long it is, and the differences between them. That last part is what settles index < length(items), which is the clause the whole detour was about. The clause itself crosses a module boundary whole, unlike a refinement predicate, because a refinement is a proof the declaring module already did and a precondition is a question only the caller can answer. What travels with it is what each name in it refers to, worked out where it was written, since a clause's spans are offsets into that file and nowhere else.

A refinement could say the same thing about a length and could not be settled, which is the kind of gap you only find by writing the two side by side. value has no declaration of its own, so it travelled as a range, and a range answers value > 0 and cannot answer length(value) > 0: that is a question about a term and a term needs something to be keyed by. So if length(s) > 0 proved a where clause saying length(s) > 0 and left a refinement saying the same thing Guarded, over the same fact, two lines apart. A value being checked now carries what is known about it three ways, the range it lands in, how long it is, and the name it was given when it has one. The name is the strongest of the three, since it is the entry the body has been narrowing all along. The length covers the case with no name to give, so "" where a non-empty string is wanted is refused outright instead of being left to a check at runtime, and a string written on the spot proves the refinement it is being passed into.

The same thing was missing from both of the other directions, which is what a gap like that usually turns out to mean. A parameter already of a refined type is a fact without a where clause repeating it in prose, and that held for what the value is worth and not for how long it is, so a NonEmptyList knew nothing about its own length inside the body that declared it. And narrowing into a refinement asked for the base type exactly rather than asking the question the base type would have been asked, so first_of([]) came back as "expected NonEmptyList, found List<_>". The empty list fits a List<Int> perfectly well. What is wrong with it is the predicate, and that is what it says now.

That also answered a question the design doc had listed as open. A refinement has no conversion form, and it turns out not to need one: try_positive(n) returning a Result is what Positive.try(n) would have been, it is ordinary code, and the ok is Proven because the guard above it says what the predicate wants. What a built-in form would buy is the predicate living in one place rather than being restated at every converter, and a restatement weaker than the predicate is the mistake it would rule out. That one is still accepted today with a runtime check rather than refused, which is honest and is not the same thing as catching it.

It still does not finish the job. at returns a Result whatever is known about its index, so a caller that proved the bound still writes a match for a failure that cannot happen. A total indexing form is a precondition on a prelude function, which is a thing the language can now express, and whether the prelude should carry one is a question rather than an oversight.

It also used to refuse n + 1 on a Positive, and this paragraph used to say that was the reasoning working. It was not. Overflow is an error rather than a wrap, so n + 1 either produces a value or stops the program, and it never produces a wrong one; so a value that exists is inside Int, and any sum that exists is greater than one. The interval clamps at the edge instead of collapsing, and the runtime check that used to be emitted there could never have fired. Two of the three warnings in the file went away and the comments explaining them turned out to be the more interesting half of the fix.

What it still cannot do is relate two names through a product, so result == n * n says nothing. design/02-syntax.md lists the rest.

transfer.deed used to model something that could not exist. Money.units was Positive, which made a zero balance and a debit unwritable, and the type checker said so. The fix was to separate the type that can be zero from the type that cannot, which is the sort of thing the language is supposed to force and did.

hello.deed was worse. The test written to prove that a function without a Console cannot write to one failed, because Io.write(Console, "hi") type checked: a type name in expression position had no type, and no type agrees with everything. Capability safety was decorative for about an hour. That is now DEED4019.

closures.deed is the same shape of bug found twice in one place. A parameter could be written with no type, which made it the unknown type, and a closure's effects were charged to nobody. Either alone is arguable. Together they meant a closure could carry any effect into any function with the row staying empty the whole way. A parameter now needs a type, and a closure's effects are charged to whoever wrote it. A closure can leave the function that wrote it, and the type it leaves through says what it may do on the way: Fn(Int) -> Int performs nothing, Fn(Int) uses Log.note -> Int performs that and no more. Leaving a row off cannot mean any row, or a value could carry an unstated effect through a signature.

diverge.deed is what a design document claiming something the compiler did not do looks like when it gets fixed. "Non-termination is an effect" had a section of its own and Diverge appeared nowhere else in the repository, so the word did not even resolve. Running an unbounded recursion overflowed the host stack and killed the process, with no diagnostic and no exit code anyone could read. Now a function that can reach itself has to declare it, mutual recursion included, and the interpreter reports DEED6009 instead of dying. There is still no termination proving, so factorial has to declare it too, and the design document says that rather than hoping.

strings.deed exists because until recently there was no way to join two strings. A program could not build a message out of pieces, so nobody could write a program, so every other decision here was untested. Fixing it turned up the same bug from the other side: < was accepted on anything as long as both sides had the same type, so comparing two records passed the type checker and failed at runtime with a message blaming the interpreter for not implementing something that has nothing to implement. It now also carries split, join, to_string and to_int, which are two pairs of inverses and are there for one reason: a program could hold text and hold a number and get from neither to the other, so it could not read input, print a count, or write anything back out.

lists.deed is the same complaint one size up. Until it, nothing in the language could hold more than one of something, so every program was one that worked on a fixed number of named variables. List is built in rather than declared, and it is the same shortcut Result takes: element types are compared componentwise and an unknown one absorbs, so [] fits wherever a list was wanted and no unification was needed anywhere. What the file is honest about is iteration: at the time there was no for, so walking a list was recursion and every walk declared Diverge. That was not an oversight. An accumulator loop wants mutation or a fold, and mutation here is supposed to be handler state and nothing else, so the shape of for was an argument about the central claim of the language rather than a piece of syntax to add.

generics.deed is what the two shortcuts were actually costing. Not the shortcuts: nobody could write a library. first, last, map and count_where are one function at different element types and none of them could be written down anywhere in this repository. A generic function costs this design almost nothing, because there is still no unification: at a call site the declared parameter types are matched against the argument types, walking down both in step, and List<T> against List<String> gives T = String. One rule carries the rest. Every type parameter has to appear in a parameter's type, which means a call always knows what every parameter is, which means no type arguments are ever written, which means f<a>(b) versus f < a > (b) is not a problem this parser has. It is also the same claim everything else here makes: a signature is complete.

generic_types.deed is the other half, and it is where the shortcut stops being one. Option was always the third generic type people reach for, and it is declared there rather than built in. A generic type is a head plus arguments compared componentwise, which is exactly how Result and List were already compared, so the mechanism was half built before it was asked for. What decides the arguments is the same matching a call does: a literal matches its declared field types against the values it was given. A field then reads at the type it was applied to, and so does a pattern binder, which is the part a test caught: Some { value } on an Option<Int> has to bind an Int rather than the T the choice was declared with.

std/list and using_list.deed are the point of the three changes above. std/list is a list library written in Deed: map, map_at, filter, filter_at, fold, fold_at, any, all, count_where, filtered_with, first, last, reversed, prepend, find, take, drop, concat, flatten, partition, zip, enumerate, windows, chunks, intersperse, unzip and flat_map, none of them known to the compiler, no builtin, no special case, no name in the prelude. It is the first thing in this repository anybody else could have written.

It ships with the compiler, and it did not always. It sat under examples/ for months, and a module's name says where it lives, so the only way to import it was use examples/list and a program written anywhere else had to copy the file. Nothing about the library changed when it moved.

It is also what got the language a way to stop a walk. any and all in it both used to open with a branch whose only job was to notice that the answer was already in, which is control flow inside a fold, which is the thing a fold exists to not have, and the branch could skip the work but not the turn. The design doc said break and continue had not come up in a program written here, and this file was the counterexample sitting in the repository. So a for takes a while now, read before each turn with the accumulator in scope and the element out of it, since the element belongs to the turn the condition is deciding whether to take. Both functions lost their branch. It is not break: nothing is abandoned, the value of the loop is the accumulator it stopped holding, and the list still bounds how many turns there can be, so the termination argument that keeps a while statement out is untouched.

Pointing todo.deed at it found the last thing missing. The compiler only looks at the files it was handed, which is a rule worth having, and it meant deed run examples/todo.deed could not find the library and the workaround was to name every file the program transitively needs. A library nobody can use without knowing its file layout is not a library. A module's name says where it lives now: a module named a/b is at <root>/a/b.deed, and the root comes from taking a named file's module path off the end of its own path. No search path, no config file, no manifest, and it is a rule every file here already followed.

What was named is the subject and what an import needed is context, so deed test app.deed does not run a library's tests and deed check app.deed does report a library's errors. That is the same split the language server already makes between the workspace and the open document.

What std/list needed last was a row variable. Before that there were two ways to write map and both were wrong: Fn(A) -> B promises to perform nothing, so the callback could not log or read a file, and Fn(A) uses Log.note -> B works for one effect and needs a second copy for the next one. uses r stands for whatever the callback performs and passes it through to the function's own row, so map(ns, |n| n + n) performs nothing and map(ns, |n| { Log.note(..); n }) performs Log.note, and the second caller has to say so. The library says "whatever you gave me" and the caller says what that was.

Inside the body a row variable is an ordinary entry: calling the callback performs r, the contract says uses r, and the same two rules that check every other function check that one. Which is why a variable that reaches no parameter was already an error before anything was written for it, since nothing can fill it, so nothing performs it, so the row is too wide.

The design doc spent a while listing "can a row variable appear in two parameters" as an open question, on the assumption that the second occurrence was checked against the first the way a type parameter's is. Measuring it first said otherwise: nothing compares them, and the call site unions the row of every argument the variable came from. That is the useful answer, so filtered_with(ns, keep, dropped) takes a pure keep and a dropped that logs and charges the caller Log.note. A variable is not a name for one row that every parameter carrying it has to agree on, it is a name for the places a call reads a row off. The behaviour was right and untested, which is a worse position than being wrong and tested, so it is pinned now.

Looking at that machinery again turned up a hole that had been open the whole time. The pass worked out a function value's row by matching on the shape of the expression: it knew a closure written on the spot and a bare name, and answered "performs nothing" for everything else. An empty row is a claim rather than an absence, so every other shape was a claim nobody had checked, and there were five of them. A function that came back from a call, one chosen by an if, one taken out of a list, one read out of a record field, and a call applied straight to the result of another call. Each one ran an effect through a caller that declared none, and the argument in the comment for why that was safe confused two different questions: whether a value performs more than its type allows, which was checked, and what calling it costs the caller, which was not.

The row is part of the type, and the type checker already works out a type for every expression, so it hands over the row of every function-typed one and the guessing is gone. The rule that had to come with it is where a row variable may be written: the row of a parameter that is a function type, and the declaration's own uses clause, and nowhere else. A variable in a return type reaches a caller standing for something that caller has no word for, so it gets dropped, and a dropped entry is an effect that happens and is not declared. That is the same rule as the one saying a type parameter has to appear in a parameter's type, and for the same reason: a signature whose call sites cannot work out what it means is not a signature.

Five holes in one place, all found by hand, is a bad way to find out. So the interpreter holds the program to its own signatures while it runs. Each active call carries the row its declaration wrote down, every effect performed is checked against every call on the stack, and one nobody declared is an error reported against the compiler rather than the program, since the file was accepted and so the check that accepted it was wrong. A with block discharges what is inside it and a contract does not contribute to a row, so both are exempt, which are rules the language already had rather than allowances made here. Pointed at examples/, it passes, and with the fix above taken back out it fails on the first program that used to slip through. The point is not what it finds today. It is that the next one reports itself, with a stack, from a real program.

It found one the same day it was written, which is sooner than I expected and worse than I expected. A with block discharges the effect the handler implements, which is what a handler is for, and it said nothing about what the handler does to implement it. So a function handed a Console could install a handler whose operation writes to that console, run it, and declare an empty row. Everything checked clean and the screen still got written to. Those effects go to whoever installed the handler now, which is the function that made the decision, and a handler carries what it performs across a module boundary the same way a function carries its row.

The worst one so far was found the same afternoon. The Guarded tier did not guard a return value. deed check printed "so it becomes a runtime check" and there was no check: the interpreter guarded arguments and annotated lets, because those were the two places somebody had happened to write the call. A function declared to return a Positive would hand back a -5 and every caller downstream was entitled to believe it. The warning was the part that made it dangerous, since that is what convinces a reader they are covered. The two passes now read the same table, and crates/deed-driver/tests/guards.rs has a test for every place a refined value can come into existence, each one handing the guard something it is supposed to refuse. The test that should have caught this did exist, and it passed, because it only ever handed the guard values it accepts.

Those tests all had to be written in Rust, which was itself a gap and proven.deed said so in a comment for months. A contract failure ends the run, so a file of examples showing a guard refusing something could not pass, and then preconditions started being read at the call site and the checker began refusing such a file outright. The better the checking got, the further out of reach the check itself went. assert refuses order_of(0) says it from inside the language now: it passes when a where clause, an ensures clause or a refinement turns the value down, and fails when anything else happens, including the call producing a value. That is the one thing in the language that catches, and it catches those three and nothing else, because overflow and a missing handler are a program going wrong rather than a signature doing its job.

The Tested tier had a quieter version of the same problem. deed test generates a hundred inputs from a contract and shrinks whatever fails, and for a while it only shrank integers and the fields of records. So a counterexample of [95] came out when [1] says the same thing, and a variant would shrink its field to zero and stop rather than becoming the Nothing sitting next to it. A counterexample built out of something that does not shrink looks exactly like a small one that happens to be awkward, and nothing tells you which it was. Everything the generator can build shrinks now: lists get shorter before their elements get smaller, strings get shorter and then plainer, true gives way to false, and a variant gives way to a sibling that carries no fields. That last one is the only part that cannot be read off the value, so the choices are walked once and every variant is told which of its siblings are empty.

Five of these have now had the same shape: something has no type, the unknown type agrees with everything, and checking quietly stops. So there is an invariant for it now. In a file that checks cleanly, no expression is unknown, because an unknown one is an expression nothing done with it was checked against. Written as a test and pointed at the examples, it found two more in the first run and a third once those were fixed: closure bodies, handler literals, and every call to an imported effect's operation. crates/deed-driver/tests/fully_typed.rs is where the next one gets found on purpose.

Looking for the same shape one level up found the biggest one. A function's effect row did not cross a module boundary, so every call into another file was free, and in a program with more than one file that is most calls. The pass this language exists for was doing its work on the calls that mattered least. Rows travel now, and a caller that inherits an effect it never imported is told which module to import it from, because a row that cannot name what it grants is not a row.

The grammar had one of its own. Statements are separated by nothing, so what ends one is the next token not being able to continue the expression before it, and the design doc said that held only by coincidence. It did not hold at all: ( starts a parenthesised expression and continues a call, - starts a negation and continues a sum, so let a = 1 with -2 under it read as let a = 1 - 2 and the second line was gone with nothing said about it. A line break ends an expression now, and the rule is the same inside brackets as outside them, because a rule that switches off somewhere is a rule people have to remember. Nothing canonical changes shape under it, since deed fmt never breaks a binary expression across lines.

Being right is half of it. The rule makes a new mistake possible, which is carrying an operator down to the next line, and both halves of that are answered. * 2 on its own line is a parse error that says why rather than only what, and takes the rest of the line with it so one mistake gets one complaint. - 2 on its own line is a perfectly good expression and there is nothing to refuse, so what is left is a statement whose value nobody reads, and saying that is DEED4026. It is a warning, because let _ = f() is how a program says it meant to throw a value away and working code should not have to be rewritten to keep compiling, and dropping a Result gets its own sentence, since that one loses the failure rather than a line.

Then the escape hatch turned out to have a hole. let _ = f() throws the value away and says so, but let b = f() silences the warning too, and now there is a name nobody reads, which is the same statement doing nothing with an explanation attached. So a let binding one plain name that no expression mentions is DEED3009. The measurement is the interesting part: pointing it at every binder fired twenty seven times across the examples and twenty five of those were not mistakes, because a pattern is there to match and err(why) names what the shape holds, a parameter's shape is the signature and a handler's belongs to the effect it implements, and a for binder walks whether or not the element is wanted. A let is the one form whose entire reason for existing is the name. Narrowed to that, it fires once in the whole corpus, on a binding an example had already called unused.

deed fmt prints one canonical form and takes no options for the output. P4 said formatting is not configurable long before anything enforced it, which meant the files were formatted the way they happened to have been typed. A test now asserts that every .deed file in the repository is already canonical, so the principle either holds or the build fails.

deed fix is the same move for P7. Diagnostics already carried a patch and a note about whether that patch is certain or a guess, and nothing applied them, so what P7 described was a data structure. deed fix applies the certain ones and refuses the guesses, with no flag to override that. Which left the guesses reachable by nothing, and the certain ones reachable only from a place the reader has already left, until the language server started offering both as quick fixes.

It writes rows now, which is the part that costs something day to day. A fix is a span and a replacement, so most of them are handed over where the problem is found, and the row diagnostics could not: DEED5001 names the effect, names the function and tells you to add it to the uses clause, and saying that as a span means knowing about commas, about indentation, and about a clause that may not be there yet. The effect checker has the answer and no business knowing any of that, so the driver writes those, where the text, the tree and the one canonical layout are all in scope. deed fix fills in a row that is too narrow, takes out an entry that is never performed, and what it writes is exactly what deed fmt would have.

It declines twice, and both are the point. A contract holding a where or an ensures is left alone, since the region between a signature and its body holds all three and nothing in the tree says where one stops and the next starts. And a comment anywhere in that region stops it, because rewriting the block would eat the comment, and a machine-applicable fix that deletes a comment is a fix nobody should have applied.

An unused import goes the same way, one pass earlier. DEED3003 knows which name nothing uses and knows nothing about the comma beside it, and taking the last name out of a list is a question about a line and the blank line next to it. One fix for the whole block rather than one per name, which is not tidiness: two names out of the same list are two edits over overlapping text and both get dropped, and two whole lines removed separately each leave behind the blank that used to sit beside them, so the file ends up with a gap that no longer separates anything. Rewriting the block once has neither problem, and what it writes is what deed fmt would have.

The two warnings about something going nowhere carry a guess each, and a guess is what the editor is for. DEED3009 offers _spare, DEED4026 offers let _ = in front of the line, and neither is ever applied in bulk, because the other way to arrive at either one is a value that was supposed to be read and papering over that is the single thing they must not do. DEED4026 is also the first fix the type checker has ever carried. A type that does not fit has no obvious repair, which is why there were none, and this one has exactly one mechanical answer that is still not certain to be what was meant.

deed check --timings is the same move for P9, which said check latency is budgeted and had never been measured. The guard is not a wall clock budget, since a test that fails on a busy machine is a test people learn to rerun. It is the shape of the curve: ten times the input has to cost well under a hundred times the time. That test found a quadratic on its first run, in the "did you mean" suggestions, which cost an edit distance against every name in scope for every name that failed to resolve.

names.deed and greeting.deed are two modules that see each other. A module is named by its own module line, and the unit of compilation is the set of files you handed the compiler, so deed check examples/ resolves the use in one against the declarations in the other. A use of a module that is not there is an error, so is a name it does not declare, so is a field an imported record does not have, so is a match on an imported choice that forgets a variant. None of that was checked before: an imported name had no type, and a name with no type agrees with everything, so a module boundary was a place where checking stopped.

Running crosses it too. A call through an import walks into the other module's body with that module's own names in scope, and a variant carries the module that declared it, so the same variant reached two ways is one value. Effects cross as well: sink.deed declares an effect and two handlers, greeting.deed performs the effect and installs the handlers, and the row is checked against the declaration in the other file in both directions.

No dependencies. cargo test runs the whole thing, and one of the tests is that examples/transfer.deed survives every pass.

deed check runs every pass even when an earlier one failed. Stopping at the first failure would cost a round trip for everything the later passes would have found, and the design makes running on safe: parse errors become error nodes, unresolved names become unknown types, and unknown agrees with everything. Diagnostics come out in source order rather than pass order, because a reader works down the file and which pass noticed is not their problem.

What Deed is deliberately not doing

No macros, no inheritance, no exceptions, no operator overloading, no implicit conversions, no optional syntax. Each of those makes a function impossible to understand without reading something else, which is exactly the cost this language exists to remove.

Deed is less pleasant to write by hand than most modern languages. That is the trade, made on purpose.

The obvious problem

There is no training data for a language nobody has written yet, and no ecosystem either. The only real answer to the first is to keep the specification small enough to read in full, in one sitting, so the language never has to be remembered. That is a hard constraint on the design, not a nice-to-have. The answer to the second is interop, and it is not solved yet.

If you think there is a better answer, please open an issue.

Contributing

See CONTRIBUTING.md. Short version: the design is the thing that needs attacking right now, not the code.

License

Apache-2.0. See LICENSE.

Releases

Packages

Contributors

Languages