Azure / azure-functions-durable-js

JavaScript library for using the Durable Functions bindings
https://www.npmjs.com/package/durable-functions
MIT License
128 stars 46 forks source link

weak typing for yield results on `callActivity` #524

Open Crisfole opened 12 months ago

Crisfole commented 12 months ago

Describe the bug

The choice to use Generators for Durable Functions in Azure was deliberate as I understand it, but it has made strongly typing the APIs for Orchestrations and Activities very challenging. With custom Promise implementations the typing is pretty straightforward, but the replay-ability is probably harder?

I don't think Typescript supports stronger typing of the types on a per-yield result basis (https://github.com/microsoft/TypeScript/issues/32523). Is there a plan for this that will make this process way less painful? I'm usually pretty fantastic at hacking around stuff like this with typescript, but I can't figure out how in this scenario (specifically for activity return types).

Investigative information

If deployed to Azure App Service

Local only at this time.

If you don't want to share your Function App name or Functions names on GitHub, please be sure to provide your Invocation ID, Timestamp, and Region - we can use this to look up your Function App/Function. Provide an invocation id per Function. See the Functions Host wiki for more details.

To Reproduce

Check out the ActivityCaller type below. VS Code complains (with good reason) that the Output parameter is unused:

import type { FunctionInput, FunctionOutput, InvocationContext } from "@azure/functions";
import type { OrchestrationContext, ActivityHandler, RetryOptions } from "durable-functions";

import { app } from "durable-functions";

type ActivityExtraOptions = Partial<{
  extraInputs: FunctionInput[];
  extraOutputs: FunctionOutput[];
}>;

type CallOptions = Partial<{
  retry: RetryOptions;
}>;

type Handler<Input, Output> = (input: Input, ctx: InvocationContext) => Promise<Output>;

// LOOK HERE: I cannot type this in any meaningful way that results in a type I can manipulate into
// const out: Output = yield callThisFunction(ctx, "input");
type ActivityCaller<Input, Output> = (ctx: OrchestrationContext, input: Input, { retry }?: CallOptions) => Task;

export function createActivity<Input, Output>(name: string, handler: Handler<Input, Output>): ActivityCaller<Input, Output>;
export function createActivity<Input, Output>(name: string, options: ActivityExtraOptions, handler: Handler<Input, Output>): ActivityCaller<Input, Output>;
export function createActivity<Input, Output>(
  name: string,
  handlerOrOptions: ActivityHandler | ActivityExtraOptions,
  maybeHandler?: ActivityHandler,
): ActivityCaller<Input, Output>  {
  let options = typeof handlerOrOptions === "function" ? {} : handlerOrOptions;
  let handler = typeof handlerOrOptions === "function" ? handlerOrOptions : maybeHandler;
  if (handler == null) {
    throw new Error("Options were passed, but no handler was passed?");
  }

  app.activity(name, {
    ...options,
    handler,
  });

  return Object.defineProperty(
    function (ctx: OrchestrationContext, input: Input, { retry }: CallOptions = {}) {
      if (retry) {
        return ctx.df.callActivityWithRetry(name, retry, input) as Output;
      } else {
        return ctx.df.callActivity(name, input) as Output;
      }
    },
    "name",
    { value: name }
  );
}

Expected behavior

There is some way other than as or type assertions in the orchestrator to bind the return type of a called function to the Task result type.

Actual behavior

There is not such a technique that I know of.

Screenshots

N/A

Known workarounds

as or manually specifying this stuff.

Additional context

N/A

Crisfole commented 12 months ago

OK, so I have to take back the "I Can't do any better'. I've added:

type TypedTask<Output> = Task & { result: Output };
export type Yielded<T extends ActivityCallerF<any> | ActivityCallerFx<any, any>> = ReturnType<T> extends TypedTask<infer O> ? O : never;

And I've used as to return those typed tasks from the caller functions:

return ctx.df.callActivityWithRetry(name, retry, input) as TypedTask<Output>;

And in the calling orchestrator I can use them like so:

import { createActivityWithInput, type Yielded } from "../createActivity.js";
import { createOrchestrator } from "../createOrchestrator.js";

export const load = createActivityWithInput<number, string>("demo", async (input, ctx) => {
    return input.toString();
});

export const sync = createOrchestrator("sync-orchestrator", function* (ctx) {
  ctx.log("Syncing");
  const x: Yielded<typeof load> = yield load(ctx, 10);
  ctx.log("Yielded " + JSON.stringify(x) + " as " + typeof x);
});

It behaves as expected, it is still way less than ideal.