Promisifying Callbacks in TypeScript
The Context
As part of a current project, I’m working on an Adobe CEP extension to augment the behaviour of Adobe Illustrator to add new features.
As a part of this we use ExtendScript to interface with the host application.
In TypeScript, we can execute commands via CSInterface, a wrapper provided by Adobe to interact with the global
window.adobe_cep__ object, like so:const csInterface = new CSInterface(); csInterface.evalScript("alert('Hello world!')")
When called, this will present a native alert to the user.
You can also call functions you have already declared, such as:
csInterface.evalScript("saveDocument()")
The Problem (Callback Hell)
This interface is a callback-based async API; it does not use
Promise, only allowing you to handle the async result of the operation via a callback:// result must be a string - no objects csInterface.evalScript("saveDocument()", (_: string) => { csInterface.evalScript("getDocumentFilepath()", (filepath: string) => { // Some operation with the filepath csInterface.evalScript("saveDocument()") }) }) // ❌ The following does not work: // 1. await is invalid for `evalScript` // 2. if you want to wait for `saveDocument` to complete before `getDocumentFilepath`, you will have a race condition here csInterface.evalScript("saveDocument()") const filepath = await csInterface.evalScript("getDocumentFilepath()") // Some operation with the filepath csInterface.evalScript("saveDocument()")
This can force us into writing code that looks like:
csInterface.evalScript("myFunc()", (stringRes: string) => { csInterface.evalScript("myFunc()", (stringRes: string) => { csInterface.evalScript("myFunc()", (stringRes: string) => { csInterface.evalScript("myFunc()", (stringRes: string) => { csInterface.evalScript("myFunc()", (stringRes: string) => { csInterface.evalScript("myFunc()", (stringRes: string) => { // Callback hell – difficult to understand and hard to decouple // Extracting each to its own pure function can help csInterface.evalScript("myFunc()") }) }) }) }) }) })
The Solution
You can wrap this in a
Promise to start to use the async/ await API like so:import { csInterface } from "CSInterface"; interface ScriptArgs { myFunc: [arg1: string, arg2: number] } interface ScriptReturnTypes { myFunc: { [k: string]: string } } interface ScriptResponse<T> { data: T | null; error: string | null; } type FnName = keyof ScriptArgs; export const evalIllustratorScript = async <T extends FnName>( fnName: T, ...args: ScriptArgs[T] ): Promise<ScriptReturnTypes[T]> => { const serializedArgs = args.map((a) => JSON.stringify(a)).join(","); const script = `${fnName}(${serializedArgs})`; return new Promise((resolve, reject) => { csInterface.evalScript(script, (result: string) => { try { // We use type casting here (`as`), as we are dealing with non-TS // We ensure every script that can be run returns a type compliant with `ScriptResponse` const parsed = JSON.parse(result) as ScriptResponse<ScriptReturnTypes[T]>; if (parsed.error) return reject(new Error(parsed.error)); resolve(parsed.data as ScriptReturnTypes[T]); } catch { reject(new Error(`Invalid JSON returned: ${result}`)); } }); }); };
This means we can write code like this now:
const res = await evalIllustratorScript("myFunc", "ABC", 123) // type is `{ [k: string]: string }` console.log(res)
We also get a type error if we pass in an incorrect script name or incorrect arguments to the function.
In the future, we hope to automate our generation of
ScriptArgs and ScriptResponse interfaces by directly using TypeScript to generate our ExtendScript and remove the need for type casting.