synchronous call/cc
Other approaches use setTimeout or Promise and forces the caller to deal with asynchrony. Synchronous callcc can be implemented with try-catch. Let's first see the naive approach -
// naive
const callcc = f => {
try { return f(value => { throw value }) }
catch (e) { return e }
}
console.log(5 + callcc(exit => 10 * 3))
console.log(5 + callcc(exit => 10 * exit(3)))
console.log(5 + callcc(exit => { exit(10); return 3 }))
console.log(5 + callcc(exit => { throw Error("oh no!") }))
35 ✅ 5 + 10 * 3
8 ✅ 5 + 3
15 ✅ 5 + 10
5Error: oh no! ❌ the thrown Error was treated as a return value
Wups! If we use throw for the continuation's return, how does catch know whether we called the continuation or a real error occurred? The important bit is that the continuation must "box" the return value and "unbox" it in catch to avoid swallowing real errors -
const callcc = f => {
class Box { constructor(v) { this.unbox = v } }
try { return f(value => { throw new Box(value) }) }
catch (e) { if (e instanceof Box) return e.unbox; throw e }
}
console.log(5 + callcc(exit => 10 * 3))
console.log(5 + callcc(exit => 10 * exit(3)))
console.log(5 + callcc(exit => { exit(10); return 3 }))
console.log(5 + callcc(exit => { exit(10); throw Error("test failed") }))
try {
console.log(5 + callcc(exit => { throw Error("test passed!") }))
}
catch (e) {
console.error(e)
}
.as-console-wrapper { min-height: 100%; top: 0; }
35 ✅ 5 + 10 * 3
8 ✅ 5 + 3
15 ✅ 5 + 10
15 ✅ 5 + 10
Error: test passed! ✅ Errors are not swallowed
short circuit
Given a list of numbers, let's multiply all of them. As humans we know if a single 0 is present, the product must be 0. callcc allows us to encode that same short-circuiting behavior. In the demo below mult(a,b) is used so we can see when actual work is happening. In a real program it could be replaced with a * b -
const callcc = f => {
class Box { constructor(v) { this.unbox = v } }
try { return f(value => { throw new Box(value) }) }
catch (e) { if (e instanceof Box) return e.unbox; throw e }
}
const apply = (x, f) => f(x)
const mult = (a, b) => {
console.log("multiplying", a, b)
return a * b
}
console.log("== without callcc ==")
console.log(
apply([1,2,3,0,4], function recur(a) {
if (a.length == 0) return 1
return mult(a[0], recur(a.slice(1)))
})
)
console.log("== with callcc ==")
console.log(
callcc(exit =>
apply([1,2,3,0,4], function recur(a) {
if (a.length == 0) return 1
if (a[0] == 0) exit(0) //
return mult(a[0], recur(a.slice(1)))
})
)
)
.as-console-wrapper { min-height: 100%; top: 0; }
== without callcc ==
multiplying 4 1
multiplying 0 4 here we know the answer must be zero but recursion continues
multiplying 3 0
multiplying 2 0
multiplying 1 0
0
== with callcc ==
0 the answer is calculated without performing any unnecessary work
other implementations
Here we trade the Box class for a lightweight Symbol -
const callcc = f => {
const box = Symbol()
try { return f(unbox => { throw {box, unbox} }) }
catch (e) { if (e?.box == box) return e.unbox; throw e }
}
console.log(5 + callcc(exit => 10 * 3))
console.log(5 + callcc(exit => 10 * exit(3)))
console.log(5 + callcc(exit => { exit(10); return 3 }))
console.log(5 + callcc(exit => { exit(10); throw Error("test failed") }))
try {
console.log(5 + callcc(exit => { throw Error("test passed!") }))
}
catch (e) {
console.error(e)
}
35 ✅ 5 + 10 * 3
8 ✅ 5 + 3
15 ✅ 5 + 10
15 ✅ 5 + 10
Error: test passed! ✅ Errors are not swallowed
caveat
In languages that support first-class continuations, you can return the continuation itself and call it at a later time. In JavaScript we cannot support this as the continuation is no longer run in the try-catch context. If you want to prevent the caller from misusing the continuation in this way, we can throw a specific error -
const callcc = f => {
const box = Symbol()
const exit = unbox => { throw {box, unbox} }
try {
const result = f(exit)
if (result === exit) throw Error("cannot return unbounded continuation")
return result
}
catch (e) {
if (e?.box == box) return e.unbox;
throw e
}
}
console.log(5 + callcc(exit => 10 * 3))
console.log(5 + callcc(exit => 10 * exit(3)))
console.log(5 + callcc(exit => { exit(10); return 3 }))
try {
const cc = callcc(exit => exit) // ⚠️ returns continuation
cc("hello world") // ⚠️ calls continuation out of context
}
catch (e) {
console.error(e)
}
.as-console-wrapper { min-height: 100%; top: 0; }
35 ✅
8 ✅
15 ✅
Error: cannot return unbounded continuation! ✅
remarks
Transplanting features from one language to another is not strictly forbidden but I don't recommend this as a common practice. callcc is a utility that exists in other languages that are written to support goals a feature set that differs from JavaScript. JavaScript supports a wide variety of tools and patterns that are best for supporting JavaScript programs. Whatever you are trying to do with callcc probably has an appropriate JavaScript idiom you should be using instead.