Skip to content

refactor: add chainable operations#575

Merged
CarolinaMoraes merged 3 commits intofeature/product-403-add-publish-methodfrom
refactor/add-chainable-operations
Aug 21, 2025
Merged

refactor: add chainable operations#575
CarolinaMoraes merged 3 commits intofeature/product-403-add-publish-methodfrom
refactor/add-chainable-operations

Conversation

@CarolinaMoraes
Copy link
Contributor

@CarolinaMoraes CarolinaMoraes commented Aug 20, 2025

Description

In order to achieve something like was described here:

In order to make the experience of using our SDK smoother, what do you think of implementing additional methods for create, update, and duplicate? I was thinking of something like this:

Create

await resend.templates.create(/** ... */).publish()

Update

await resend.templates.update(/** ... */).publish()

Duplicate

await resend.templates.duplicate(/** ... */).publish()

Instead of having to call the SDK twice for these operations, like:

await resend.templates.create(/** ... */);
await resend.templates.publish(/** ... */);

What do you think?

A new class ChainableTemplateResult was introduced. It uses PromiseLike to support the native Promise behavior while also enhancing the behavior of the Promise by adding the publish method to it.
The method publish can now be chained after the create and duplicate methods are executed.
(Although the chaining is possible, is not required. Users that only use create() or duplicate() won't be affected)


Summary by cubic

Adds chainable template operations so you can publish right after create or duplicate in a single call. This reduces two SDK calls to one and keeps normal await behavior.

  • New Features

    • create() and duplicate() now return a chainable result with publish().
    • Uses a PromiseLike ChainableTemplateResult to preserve await and then behavior.
    • create() internally delegates to performCreate to support chaining.
  • Migration

    • If you used .catch() on create() or duplicate(), switch to try/catch or pass an onRejected handler to then.

Related resources

#574

@CarolinaMoraes CarolinaMoraes requested a review from a team as a code owner August 20, 2025 19:32
@CarolinaMoraes CarolinaMoraes requested review from lucasfcosta and removed request for a team August 20, 2025 19:32
@CarolinaMoraes CarolinaMoraes self-assigned this Aug 20, 2025
cubic-dev-ai[bot]

This comment was marked as outdated.

Copy link
Contributor

@emiliosheinz emiliosheinz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clever solution, I like it :)

Comment on lines +59 to 63
return this.resend.post<CreateTemplateResponseSuccess>(
'/templates',
payload,
);
return data;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if this is required for it to work, but I would rather avoid returning a Promise since, without awaiting, if an error occurs, then the caller function won't appear in the stacktrace. See https://github.com/goldbergyoni/nodebestpractices/blob/master/sections/errorhandling/returningpromises.md for more details

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that if we change it to return await ... it does the job and keep the stacktrace safe, doesn't it?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think so!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

++ to this! If anyone wants more context, this is helpful.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@emiliosheinz, I didn’t think about it before
Thanks a lot for pointing it out

Unfortunately, for this solution, it needs to return the promise itself
For the chaining to work, for example create().publish()create() must return an object with a publish method immediately

If we use await, the create() method would be async. This method then would return Promise<ChainableResult> (and ChainableResult already is a Promise-like object), which we wouldn’t be able to chain since Promise<ChainableResult> doesn’t have a publish method, only ChainableResult does

As this is still under the preview branch and the implementation can change a lot until it reaches the general public, I’ll merge this
But I took note of what you and @lucasfcosta pointed out and will make sure to gather more feedback about it (maybe we even change the approach of this automatic publishing to something other than chaining)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noted! Thank you for the explanation and for looking into it.

maybe we even change the approach of this automatic publishing to something other than chaining

IMO, simplifying it by implementing the separate publish method that receives and id as you did initially, would probably make it more predictable, easier to use and maintain :)

Copy link
Contributor

@lucasfcosta lucasfcosta left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given we were already talking about traces, I have left a comment about how we could better preserve stack traces when using these PromiseLike objects.

Still, I would not block merging this PR because of that given that it introduces some significant complexity for a situation that probably does not happen often and limited benefits.

Comment on lines +59 to 63
return this.resend.post<CreateTemplateResponseSuccess>(
'/templates',
payload,
);
return data;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

++ to this! If anyone wants more context, this is helpful.

constructor(private readonly resend: Resend) {}

async create(
create(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One issue that we might have with these new PromiseLikes is that we can miss stack traces. This is related to @emiliosheinz's comment.

I don't think this is a must but I thought it was worth commenting given other people were talking about traces.

In here, we're creating a PromiseLike so we're not marking the create function as async nor awaiting on the actual creation and thus Node won't have its frame on the stack.

You can run the following test to confirm this behaviour:

it('test async stack trace for create', async () => {
  const mockReactComponent = {
    type: 'div',
    props: { children: 'Welcome!' },
  } as React.ReactElement;

  // Mock renderAsync to throw an error
  mockRenderAsync.mockRejectedValueOnce(new Error('React rendering failed'));

  const payload: CreateTemplateOptions = {
    name: 'Welcome Email',
    react: mockReactComponent,
  };

  const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop');

  // This function will call create and we want to see it in the trace
  const createTemplate = async () => {
    return await resend.templates.create(payload);
  };

  // The error should be thrown and the stack trace should include the createTemplate function, but it doesn't
  try {
    await createTemplate();
    fail('Should have thrown an error');
  } catch (error) {
    // I recommend keeping this log so you can actually copy it and compare
    const stack = (error as Error).stack;
    console.log('Stack trace:', stack);

    expect(error).toBeInstanceOf(Error);
    expect((error as Error).message).toContain('React rendering failed');

    // This fails because `createTemplate` is not part of the stack at all but it should have been
    expect(stack).toContain('createTemplate');
  }
});

If you do that you will see that the top of your stack trace has the line for React rendering failed (that's what L63 corresponds to for me, but it does not include Templates.create or even createTemplate (L74` on the trace.

at /Users/lucasfcosta/repositories/resend-node/src/templates/templates.spec.ts:63:45
at Generator.next (<anonymous>)
at /Users/lucasfcosta/repositories/resend-node/src/templates/templates.spec.ts:8:71
at new Promise (<anonymous>)
at Object.<anonymous>.__awaiter (/Users/lucasfcosta/repositories/resend-node/src/templates/templates.spec.ts:4:12)
at Object.<anonymous> (/Users/lucasfcosta/repositories/resend-node/src/templates/templates.spec.ts:56:56)
at Promise.then.completed (/Users/lucasfcosta/repositories/resend-node/node_modules/.pnpm/jest-circus@29.7.0/node_modules/jest-circus/build/utils.js:298:28)
<further internals omitted>

However, if you use something like Error.captureStackTrace when creating the object you will be able to capture the stack at that point in time and you can then enrich again when throwing. That way, you will see the actual Templates.create at the top of the stack, as well as its caller.

at Templates.create (/Users/lucasfcosta/repositories/resend-node/src/templates/templates.ts:33:12)
at /Users/lucasfcosta/repositories/resend-node/src/templates/templates.spec.ts:74:39
at Generator.next (<anonymous>)
at /Users/lucasfcosta/repositories/resend-node/src/templates/templates.spec.ts:8:71
at new Promise (<anonymous>)
at Object.<anonymous>.__awaiter (/Users/lucasfcosta/repositories/resend-node/src/templates/templates.spec.ts:4:12)
at createTemplate (/Users/lucasfcosta/repositories/resend-node/src/templates/templates.spec.ts:73:41)
at /Users/lucasfcosta/repositories/resend-node/src/templates/templates.spec.ts:79:15
<further internals omitted>

If we were to capture the trace what we'd have to do is something like:

export class ChainableTemplateResult<
  T extends CreateTemplateResponse | DuplicateTemplateResponse,
> implements PromiseLike<T>
{
  private readonly stackTraceTarget: any = {};

  constructor(
    private readonly promise: Promise<T>,
    private readonly publishFn: (
      id: string,
    ) => Promise<PublishTemplateResponse>,
  ) {
    // Capture stack trace excluding this constructor for better error reporting
    if ('captureStackTrace' in Error) {
      Error.captureStackTrace(this.stackTraceTarget, ChainableTemplateResult);
    }
  }
  
  // ...
  
    then<TResult1 = T, TResult2 = never>(
    onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null,
    onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
  ): PromiseLike<TResult1 | TResult2> {
    return this.promise.then(
      onfulfilled,
      (reason: unknown) => {
        // add some logic to recover the trace, message, and name from the captured trace
      }
    );
  }
  
    // ...
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks a lot @lucasfcosta for the thorough feedback on this (and also the potential solution to this issue)!!
As this branch is still for internal/beta use and won't be available for the general public, this implementation can still change a lot. So I'll go ahead and merge it
But this review didn't go unnoticed. I'll gather more feedback on this method usage to see whether we want to use this Promise-like approach still, or if we should minimize the complexity doing this another way

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good @CarolinaMoraes! Thanks for the work on this, I especially liked the elegant typing that went into this 🖤

@CarolinaMoraes CarolinaMoraes merged commit 98dad14 into feature/product-403-add-publish-method Aug 21, 2025
7 checks passed
@CarolinaMoraes CarolinaMoraes deleted the refactor/add-chainable-operations branch August 21, 2025 18:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants