Created
November 3, 2023 15:07
-
-
Save ericyd/666820a1ef73e1b0751f639ccefb2648 to your computer and use it in GitHub Desktop.
Async Validation with Generators
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* Running multiple validations asynchronously can present some interesting challenges, | |
* especially if the calling function should return a "result" type (or some other non-throwing value). | |
* There is even more complexity if you want to run the validations in sequence, | |
* and stop on the first failure. | |
* | |
* It is fairly easy to construct a meta function that executes the validators imperatively, | |
* but what if you want a more generic approach? | |
* This gist suggests a generic solution using generators, and slowly builds up the intuition | |
* until the final example shows a more realistic use case. | |
*/ | |
/** | |
* [1] Sync generator, just for reference | |
*/ | |
function *syncGen() { | |
for (let i = 0; i < 5; i++) { | |
yield i | |
} | |
} | |
function main1() { | |
for (const i of syncGen()) { | |
console.log(`sync n=${i}`) | |
} | |
} | |
// main1() // uncomment to see how it works | |
/** End [1] */ | |
const sleep = async (n, name) => new Promise(resolve => setTimeout(() => {console.log(`${name} n=${n} (setTimeout)`); resolve(n)}, n * 30)) | |
/** | |
* [2] simple async generator that waits for a simple timeout promise to resolve before yielding | |
* (don't mind the number in the name, it's just so numbers match between "main" and "asyncGen" functions) | |
*/ | |
async function *asyncGen2(name) { | |
for (let i = 0; i < 5; i++) { | |
yield await sleep(i, name) | |
} | |
} | |
async function main2() { | |
for await (const i of asyncGen2('async2')) { | |
console.log(`async2 n=${i}`) | |
} | |
} | |
// main2() // uncomment to see how it works | |
/** End [2] */ | |
/** | |
* [3] async generator demonstrating a sequence through a known list of promises | |
*/ | |
async function *asyncGen3(name) { | |
const promises = Array(5).fill(0).map((_, i) => sleep(i, name)) | |
// // this is the intuitive working solution | |
// for await (const i of promises) { | |
// yield i | |
// } | |
// this is kind of interesting that you don't actually need to explicitly await the promise | |
for (const i of promises) { | |
yield i | |
} | |
} | |
async function main3() { | |
for await (const i of asyncGen3('async3')) { | |
console.log(`async3 n=${i}`) | |
} | |
} | |
// main3() // uncomment to see how it works | |
/** end [3] */ | |
/** | |
* [4] demonstrates that even if you interrupt the generator, if the promises are eagerly called | |
* in the generator, they will still resolve. This is not necessarily what we want. | |
* Key observation: "async4 n=2" is the highest number logged from this loop, but | |
* the setTimeout functions are logged all the way up to "async4 n=4 (setTimeout)" | |
*/ | |
async function main4() { | |
for await (const i of asyncGen3('async4')) { | |
console.log(`async4 n=${i}`) | |
if (i === 2) break | |
} | |
} | |
// main4() // uncomment to see how it works | |
/** end [4] */ | |
/** | |
* [5] is a way to structure the async generator so the promises are lazily called, and will not | |
* execute if the generator is interrupted. | |
* Key observation: logging ends at "async5 n=2"; the generator goes up to 4, | |
* but 3 and 4 are never called. | |
* This is a simple adjustment; you just need a list of functions that return the promise, instead | |
* of a list of actual promise. | |
*/ | |
async function *asyncGen5(name) { | |
const promises = Array(5).fill(0).map((_, i) => () => sleep(i, name)) | |
for (const fn of promises) { | |
yield fn() | |
} | |
} | |
async function main5() { | |
for await (const i of asyncGen5('async5')) { | |
console.log(`async5 n=${i}`) | |
if (i === 2) break | |
} | |
} | |
// main5() // uncomment to see how it works | |
/** end [5] */ | |
/** | |
* [6] a more realistic example of processing multiple asynchronous validators | |
*/ | |
/** | |
* @typedef {object} OkValidation | |
* @property {true} ok | |
*/ | |
/** | |
* @typedef {object} ErrValidation | |
* @property {false} ok | |
* @property {string} error | |
*/ | |
/** | |
* @callback Validator | |
* @param {any} input (arbitrary for demonstration) | |
* @returns {Promise<OkValidation | ErrValidation>} | |
*/ | |
/** | |
* @type {Validator} | |
*/ | |
async function validatorA() { | |
console.log(`executing validatorA`) | |
return { ok: true } | |
} | |
/** | |
* @type {Validator} | |
*/ | |
async function validatorB(input) { | |
console.log(`executing validatorB`) | |
return { ok: false, error: `validatorB failed: ${input}` } | |
} | |
/** | |
* @type {Validator} | |
*/ | |
async function validatorC(input) { | |
console.log(`executing validatorC: ${input}`) | |
return { ok: true } | |
} | |
async function *asyncValidation(input) { | |
const validators = [ | |
validatorA, | |
() => validatorB(input), | |
() => validatorC(input), | |
] | |
for (const validator of validators) { | |
yield validator() | |
} | |
} | |
/** | |
* @typedef {object} Result | |
* @property {boolean} ok | |
* @property {string | null} error only present if ok is false | |
* @property {string} value only present if ok is true | |
*/ | |
/** | |
* A demo function that returns. | |
* Key observation: `validatorC` will never be called, because `validatorB` fails. | |
* @returns {Promise<Result<string | Validation>>} | |
*/ | |
async function main6() { | |
for await (const result of asyncValidation('async validation example')) { | |
if (!result.ok) { | |
return result | |
} | |
} | |
return { ok: true, value: 'completed all validations' } | |
} | |
// a little extra ceremony required to log the result without a top-level await | |
(async () => console.log('main6', await main6()))() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment