last updated: January 04, 2026
5 minute read
JavaScript-Style Promises in Lua
If you've worked with Neovim's asynchronous APIs, you've likely encountered their callback-based
design. For example, vim.system accepts two arguments: a table of strings to form a shell command
and a callback to run when the shell command completes.
vim.system({ "sleep", "3" }, function(out)vim.print("status code: " .. tostring(out.code))end)
Running this code, you'll notice that the editor remains responsive - the sleep function is run in
a background thread. That's great, but it comes at the cost of an extra level of indentation. One
additional level on indentation is fine, but callback-based APIs have a tendency to scale poorly
when nested:
vim.system({ "sleep", "3" }, function(out)vim.print("sleep 3 status code: " .. tostring(out.code))vim.system({ "sleep", "2" }, function(out)vim.print("sleep 2 status code: " .. tostring(out.code))vim.system({ "sleep", "1" }, function(out)vim.print("sleep 1 status code: " .. tostring(out.code))end)end)end)
When using vim.system, you can skip the callback approach entirely by appending :wait():
local out3 = vim.system({ "sleep", "3" }):wait()vim.print("sleep 3 status code: " .. tostring(out3.code))local out2 = vim.system({ "sleep", "2" }):wait()vim.print("sleep 2 status code: " .. tostring(out2.code))local out1 = vim.system({ "sleep", "1" }):wait()vim.print("sleep 1 status code: " .. tostring(out1.code))
Using wait() solves the indentation issue, but the editor is left unresponsive - the shell command
blocks the main thread. Is there a way to combine the ergonomics of wait() with the asynchronous
behavior of the callback argument?
Mimicking JavaScript's await
The core of this approach uses three functions: async, await, and a promise - I work in web
development, so this is the pattern I'm most comfortable with.
In JavaScript, we would "promisify" vim.system by creating a promise that resolves in the
callback:
const promise = new Promise((resolve) => {// not a real js APIvim.system(["sleep", "3"], (out) => {resolve(out);});});async function main() {const out = await promise;console.log("status code: " + out.code);}main();
Let's try to follow the same pattern:
local function promise(resolve)vim.system({ "sleep", "3" }, function(out)resolve(out)end)end-- how can we create this syntax?async local function main()local out = await promise -- and this?vim.print("status code: " .. tostring(out.code))endmain()
What we want from await is to begin executing the promise, pause, resume when the async action in
the promise is complete, and continue. Using coroutines, we can achieve this with just a few more
lines of code than with JavaScript.
local function promise(resolve)vim.system({ "sleep", "3" }, resolve)end--- @param promise fun(resolve: fun():nil):nillocal await = function(promise)local thread = coroutine.running()local resolve = function() coroutine.resume(thread) endpromise(resolve)coroutine.yield()endlocal function main()vim.print "start"await(promise)vim.print "end"endcoroutine.resume(coroutine.create(main))vim.print "after resume"-- prints:-- start-- after resume-- end
Let's walk through the execution of this code:
- A coroutine is created with the function
mainand immediately resumed "start"is printedawaitis called withpromisepromiseis called, triggering the 3 second timer and schedulingresolveto be called at the end of the timercoroutine.yield()pauses the coroutine runningmain"after resume"is printed- 3 seconds pass, triggering the callback passed to
vim.system- which callsresolve resolvecallscoroutine.resume(), which resumes the coroutine that's runningmain"end"is printed
The pausing logic works great, but how can we get access to the out resolved value? Two small
changes:
- Accept arguments in
resolveand pass them along tocoroutine.resume - Return
coroutine.yield()
--- @param promise fun(resolve: fun():nil):nillocal await = function(promise)local thread = coroutine.running()local resolve = function(...) coroutine.resume(thread, ...) endpromise(resolve)return coroutine.yield()end
Now when resolve is called with out, it passes the value along to coroutine.resume which in
turn passes it to the coroutine.yield. Check out the docs for
more info on passing values back and forth between resume and yield.
Another shortcoming of the current await function is that it assumes that resolve isn't called
immediately i.e. if it was called immediately, then the coroutine would first resume then
yield - with nothing to resume the paused coroutine afterwards! A safer approach is to wrap
promise in vim.schedule_wrap, which ensures the callback executes in a future event loop cycle.
--- @param promise fun(resolve: fun():nil):nillocal await = function(promise)local thread = coroutine.running()assert(thread ~= nil, "`await` can only be called in a coroutine")local scheduled_promise = vim.schedule_wrap(promise)local resolve = function(...) coroutine.resume(thread, ...) endscheduled_promise(resolve)return coroutine.yield()end
Updating main:
local function promise(resolve)vim.system({ "sleep", "3" }, resolve)end-- local await ...local function main()local out = await(promise)vim.print("status code: " .. tostring(out.code))endcoroutine.resume(coroutine.create(main))vim.print "after resume"-- prints:-- after resume-- status code: 0
Mimicking JavaScript's async
Notice that await assumes that it's called in the context of a coroutine (it uses
coroutine.running) which is why the code above wraps main in a coroutine.resume and
coroutine.create. However this code has a flaw: errors thrown in main are silently swallowed!
Try running this snippet:
local function main()error "Swallowed!"endcoroutine.resume(coroutine.create(main))
Thankfully, coroutine.resume returns its error in a pcall-like fashion, and which can then be
re-thrown with a helper:
local function safe_resume(...)local ok, err = coroutine.resume(...)if not ok then error(err) endendlocal function main()error "Swallowed!"endsafe_resume(coroutine.create(main))
We can extend safe_resume to also call coroutine.create in a separate helper function: async.
--- @param fn fun(...):nillocal async = function(fn)return function(...)safe_resume(coroutine.create(fn), ...)endendlocal function promise(resolve)vim.system({ "sleep", "3" }, resolve)end--- @param promise fun(resolve: fun():nil):nillocal await = function(promise)local thread = coroutine.running()assert(thread ~= nil, "`await` can only be called in a coroutine")local scheduled_promise = vim.schedule_wrap(promise)-- updated to use `safe_resolve`local resolve = function(...) safe_resume(thread, ...) endscheduled_promise(resolve)return coroutine.yield()endlocal main = async(function()local out = await(promise)vim.print("status code: " .. tostring(out.code))end)main()vim.print "after resume"
And finally, a promisified version of vim.system:
--- @param cmd string[]local vim_system = function(cmd)return function(resolve)vim.system(cmd, function(out)resolve(out)end)endendlocal main = async(function()local out = await(vim_system({ "sleep", "3" }))vim.print("status code: " .. tostring(out.code))end)main()
With async, await, and a promise, we get the best of both worlds: code that reads sequentially
without blocking the editor. Thanks for reading.
you might also like:
Test-Driven Refactoring
August 9, 2022
We should think of tests from the angle of easing refactoring rather than development