Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions grafast/grafast/src/steps/lambda.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isDev } from "../dev.ts";
import type {
PromiseOrDirect,
UnbatchedExecutionExtra,
Expand All @@ -8,6 +9,8 @@ import type { Step } from "../step.ts";
import { UnbatchedStep } from "../step.ts";
import { sideEffect } from "./sideEffect.ts";

const warnedCallbacks = new WeakSet<Function>();

/**
* Calls the given lambda function for each tuple
*/
Expand Down Expand Up @@ -72,6 +75,15 @@ function lambda<const TInMultistep extends Multistep, TOut>(
"lambda callback should accept one argument, perhaps you forgot to destructure the arguments?",
);
}
if (isDev && !fn.name && !warnedCallbacks.has(fn)) {
warnedCallbacks.add(fn);
console.warn(
`lambda() was called with an anonymous (inline) callback function. ` +
`This prevents deduplication. Define the callback at file scope or ` +
`give it a name for better optimization. ` +
`See: https://grafast.org/grafast/standard-steps/lambda#define-callback-in-top-scope`,
);
}
Comment on lines +78 to +86
Copy link
Member

Choose a reason for hiding this comment

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

I'm not super keen on this; I'd rather handle it with a lint rule later. The main reason is that just not having a name is not sufficient to know that it was inline; for example we recommend EXPORTABLE(...) usage everywhere; however:

const { EXPORTABLE } = require('graphile-export')
const TWO = 2
const addTwo = EXPORTABLE((TWO) => (n) => n + TWO, [TWO])
console.log(addTwo.name)

This'll output '' since there's no name for this function.

const $in = multistep(spec);
const $lambda = new LambdaStep<UnwrapMultistep<TInMultistep>, TOut>($in, fn);
if ((fn as any).hasSideEffects) {
Expand Down
245 changes: 245 additions & 0 deletions grafast/website/grafast/plan-resolvers/best-practices.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
---
sidebar_position: 5.5
title: "Best practices"
---

# Best practices for plan resolvers

Plan resolvers are **declarative**: they build a graph of steps at plan-time,
and Gra*fast* executes that graph later in batches. Keeping this mental model in
mind leads to cleaner, faster plans. This page collects the most important
recommendations.

## Extract arguments deeply

When accessing nested argument values, prefer extracting the leaf value directly
rather than extracting an intermediate object and then pulling values from it.
This gives Gra*fast* more information about what you actually need, which
enables better optimization.

```graphql
input UserFilter {
author: String
publishedAfter: Int
}

type Query {
bookCount(search: String, filter: UserFilter): Int!
}
```

### Don't: shallow extraction then transform

```ts
function bookCount_plan($parent, fieldArgs) {
const $filter = fieldArgs.getRaw("filter");
// ✘ Creates an unnecessary intermediate lambda step
const $author = lambda($filter, (f) => f?.author);
// ...
}
```

### Do: deep extraction directly

```ts
function bookCount_plan($parent, fieldArgs) {
// ✔ One step, directly optimizable
const $author = fieldArgs.getRaw(["filter", "author"]);
const $publishedAfter = fieldArgs.getRaw(["filter", "publishedAfter"]);
// ...
}
```

You can also use the `$`-prefixed shortcut for the same result:

```ts
function bookCount_plan($parent, fieldArgs) {
const { $search, $filter } = fieldArgs;
const { $author, $publishedAfter } = $filter;
// ...
}
```

Both `.getRaw()` with a path array and the `$`-prefixed destructuring give
Gra*fast* direct visibility into exactly which leaf values you need, allowing it
to skip unnecessary work and optimize the plan more aggressively.
Comment on lines +13 to +65
Copy link
Member

Choose a reason for hiding this comment

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

This was a major concern before the "eliminate eval" logic came in. It's no longer as important, but probably worth keeping as a best practice anyway - it may become more significant again in future optimizations.


## Prefer custom steps over `lambda`

[`lambda()`](../standard-steps/lambda.md) is an escape hatch &mdash; it
processes values **one at a time** rather than in batches. This is fine for
trivial synchronous transforms (string concatenation, simple math), but for
anything more complex you should create a custom step class.
Copy link
Member

Choose a reason for hiding this comment

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

No need to jump to custom class; loadOne()/loadMany() are the bread and butter steps - most people should just use those (at least to start with).

Suggested change
anything more complex you should create a custom step class.
anything more complex you should use a batch step such as `loadOne()`,
`loadMany()`, or a custom class.


### Why custom steps are better

| | `lambda` | Custom step |
|---|---|---|
| Batching | No &mdash; called once per value | Yes &mdash; `execute()` receives the full batch |
| Deduplication | Only if callback is the same reference | Full control via `deduplicate()` |
| Optimization | None | Can implement `optimize()` |
| Side effects | Not supported (use `sideEffect()`) | Full control via `hasSideEffects` |
Comment on lines +76 to +81
Copy link
Member

Choose a reason for hiding this comment

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

I've added loadOne() to make it clear what the trade-offs are between lambda vs loadOne vs full DIY. loadOne() is generally the sweet-spot.

I've also separated out the side effects point.

Suggested change
| | `lambda` | Custom step |
|---|---|---|
| Batching | No &mdash; called once per value | Yes &mdash; `execute()` receives the full batch |
| Deduplication | Only if callback is the same reference | Full control via `deduplicate()` |
| Optimization | None | Can implement `optimize()` |
| Side effects | Not supported (use `sideEffect()`) | Full control via `hasSideEffects` |
| | `lambda` | `loadOne` | Custom step |
|---|---|---|
| Batching | No &mdash; called once per value | Yes, batched and uniqued | Yes with full control |
| Deduplication | Only if callback is the same reference | Only if callback is the same reference | Full control via `deduplicate()` |
| Optimization | None | Many automatic optimizations | Full control via `optimize()` / `finalize()` / `execute()` |
:::note[Batching may not be relevant to mutations/side effects]
According to
[the GraphQL spec](https://spec.graphql.org/September2025/#sel-GANVLDCB-BBqFxyV),
side effects may only occur in `Mutation` fields, and these fields are
executed serially. This gives no opportunity for batching. Typically
`sideEffect()` (like `lambda()`, but with side effects) is suitable for
a mutation field plan resolver, and no other steps should be needed.
Side-effects should not happen in other (non-Mutation) plan resolvers,
however any step can be marked as having side effects via:
```ts
$step.hasSideEffects = true;

This should generally be avoided for the reasons above.

:::


### When `lambda` is appropriate

- Concatenating strings: `lambda([$first, $last], ([f, l]) => \`${f} ${l}\`, true)`
- Simple math: `lambda($n, (n) => n + 1, true)`
- Trivial data mapping that doesn't benefit from batching

### When to create a custom step

- The transform involves I/O or async work &mdash; use
[`loadOne()`](../standard-steps/loadOne.md) /
[`loadMany()`](../standard-steps/loadMany.md) instead
- You want deduplication (e.g. multiple fields perform the same transform)
- The logic is non-trivial or would benefit from batching
Comment on lines +89 to +95
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
### When to create a custom step
- The transform involves I/O or async work &mdash; use
[`loadOne()`](../standard-steps/loadOne.md) /
[`loadMany()`](../standard-steps/loadMany.md) instead
- You want deduplication (e.g. multiple fields perform the same transform)
- The logic is non-trivial or would benefit from batching
### When to use loadOne/loadMany
Use [`loadOne()`](../standard-steps/loadOne.md) to loading a single record for each input, or [`loadMany()`](../standard-steps/loadMany.md) to load a collection of records for each input, when:
- you have async work (except mutations),
- you have I/O work (except mutations), or
- the code would benefit from batching.
### When to create a custom step
Custom steps can be used for any purpose, typically you'll want
to build your own step classes if:
- You want to expose your own helper APIs (e.g. custom methods on your step)
- You want full control over execution (e.g. if loadOne/loadMany's optimizations don't fit your needs)
- You want full control over deduplication (reducing redundant work)
- You want full control over plan optimization (in particular eliminating over- and under- fetching by communicating with other steps)
- You want to do custom work one time only for your step (custom `finalize()`)


### Example: custom step

```ts
import { UnbatchedStep } from "grafast";

export class FullNameStep extends UnbatchedStep<string> {
static $$export = {
moduleName: "my-app",
exportName: "FullNameStep",
};
isSyncAndSafe = true;

constructor($firstName: ExecutableStep<string>, $lastName: ExecutableStep<string>) {
super();
this.addDependency($firstName);
this.addDependency($lastName);
}

// Steps with identical dependencies are candidates for deduplication
deduplicate(peers: FullNameStep[]): FullNameStep[] {
return peers;
}

unbatchedExecute(_extra: UnbatchedExecutionExtra, firstName: string, lastName: string) {
return `${firstName} ${lastName}`;
}
}

export function fullName($first: ExecutableStep<string>, $last: ExecutableStep<string>) {
return new FullNameStep($first, $last);
}
```
Comment on lines +99 to +128
Copy link
Member

Choose a reason for hiding this comment

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

Let's not use an UnbatchedStep as an example, because... it's explicitly unbatched. It's very rare a user would ever need an unbatched step (they could just use lambda). Instead; maybe use a batch-capable API that also allows you to determine which features to fetch - e.g.

GET https://www.googleapis.com/drive/v3/files
  ?q=id in ('1abc','2def','3ghi')
  &fields=files(id,name,owners(emailAddress))

This would demonstrate the value of building your own step class with helper methods and custom execution.

Ref:


## Define lambda callbacks at file scope

If you do use `lambda`, **always define the callback at file/module scope**
(or import it from another file) rather than inline. Gra*fast* deduplicates
lambda steps by comparing the callback reference &mdash; inline arrow functions
create a new reference on every call, defeating deduplication.
Comment on lines +130 to +135
Copy link
Member

Choose a reason for hiding this comment

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

Encourage this for all functions; in particular:

  • lambda
  • loadOne
  • loadMany
  • applyInput

Less importantly:

  • each
  • filter
  • groupBy
  • partitionByIndex
  • sideEffect


### Don't: inline callback

```ts
const objects = {
User: {
plans: {
fullName($user) {
const $firstName = $user.get("firstName");
const $lastName = $user.get("lastName");
// ✘ New function reference every time — cannot be deduplicated
return lambda([$firstName, $lastName], ([f, l]) => `${f} ${l}`, true);
},
},
},
};
```

### Do: file-scoped callback

```ts
// ✔ Defined once at module scope — same reference every time
function fullname([firstName, lastName]: [string, string]): string {
return `${firstName} ${lastName}`;
}

const objects = {
User: {
plans: {
fullName($user) {
const $firstName = $user.get("firstName");
const $lastName = $user.get("lastName");
return lambda([$firstName, $lastName], fullname, true);
},
},
},
};
```

## Don't use `try`/`catch` in plan resolvers

Plan resolvers run at **plan-time**, not execution-time. They build a
declarative graph of steps &mdash; think of them like React component render
functions where steps are like hooks. Using `try`/`catch` introduces imperative
control flow that doesn't fit this model.
Comment on lines +177 to +180
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
Plan resolvers run at **plan-time**, not execution-time. They build a
declarative graph of steps &mdash; think of them like React component render
functions where steps are like hooks. Using `try`/`catch` introduces imperative
control flow that doesn't fit this model.
Plan resolvers run at **plan-time**, before any input values are known and
before any data has been fetched. They build a declarative graph of steps
that will be executed later. Since they run at plan-time, `try`/`catch`
will only catch planning errors (which shouldn't really happen!) - it will
not catch execution-time errors (i.e. errors resulting from
fetching/manipulating real data).


### Why it doesn't work

- Plan resolvers don't execute your data-fetching logic &mdash; they only
**describe** it. A `try` block around step creation doesn't catch runtime
data errors because those errors happen later, during execution.
- Wrapping step creation in `try`/`catch` can mask plan-time programming
errors that should be fixed, not caught.
- It suggests a misunderstanding of the plan/execute separation.
Copy link
Member

Choose a reason for hiding this comment

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

I really like this last bullet point.


### Don't: try/catch around steps

```ts
// ✘ This try/catch is meaningless — runtime errors happen during execution,
// not during planning
function post_author_plan($post) {
try {
const $authorId = $post.get("authorId");
return loadOne($authorId, batchGetAuthorById);
} catch (e) {
return constant(null);
}
}
```

### Do: use flow control steps
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 most users should only use flow control steps in rare occasions.

Suggested change
### Do: use flow control steps
### Do: use `maskError` or similar to process errors
GraphQL is designed to continue in the face of errors, allowing for
"partial success"; however, you may wish to relabel an error when
presenting it to a user. To do so, use Grafserv's `maskError`
functionality, or similar methods that come with your server of choice.
### If necessary: use flow control steps


Gra*fast* provides declarative flow control for handling errors and null values
at execution-time:

```ts
import { loadOne, trap, inhibitOnNull, TRAP_ERROR } from "grafast";

function post_author_plan($post) {
const $authorId = $post.get("authorId");

// Guard against null authorId — skip the load entirely
const $guardedId = inhibitOnNull($authorId);

// Load the author; if it errors, convert to null
const $author = loadOne($guardedId, batchGetAuthorById);
return trap($author, TRAP_ERROR);
Copy link
Member

Choose a reason for hiding this comment

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

Be more explicit about what we're doing here:

Suggested change
return trap($author, TRAP_ERROR);
return trap($author, TRAP_ERROR, { valueForError: "NULL" });

}
```

The key flow control steps are:

- [`inhibitOnNull()`](../standard-steps/inhibitOnNull.mdx) &mdash; suppresses
downstream work when a value is `null`
- [`assertNotNull()`](../standard-steps/assertNotNull.mdx) &mdash; turns
`null` into a `SafeError` visible to clients
- [`trap()`](../standard-steps/trap.mdx) &mdash; recovers inhibited or errored
values back into ordinary data (e.g. `null` or an empty list)
Comment on lines +232 to +233
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
- [`trap()`](../standard-steps/trap.mdx) &mdash; recovers inhibited or errored
values back into ordinary data (e.g. `null` or an empty list)
- [`trap()`](../standard-steps/trap.mdx) &mdash; recovers inhibited or errored
values back into ordinary data (e.g. `null`, an empty list, or the error as
a simple "data value" rather than an exception)


See [Thinking in plans: Flow control](../flow.mdx#flow-control) for more
details on when and how to use these.

## Summary

| Recommendation | Why |
|---|---|
| [Extract arguments deeply](#extract-arguments-deeply) | Fewer intermediate steps, better optimization |
| [Prefer custom steps over `lambda`](#prefer-custom-steps-over-lambda) | Batching, deduplication, optimization |
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
| [Prefer custom steps over `lambda`](#prefer-custom-steps-over-lambda) | Batching, deduplication, optimization |
| [Avoid `lambda`, except for inexpensive synchronous work](#prefer-custom-steps-over-lambda) | Batching, deduplication, optimization |

| [File-scoped lambda callbacks](#define-lambda-callbacks-at-file-scope) | Enables deduplication |
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
| [File-scoped lambda callbacks](#define-lambda-callbacks-at-file-scope) | Enables deduplication |
| [File-scoped callbacks](#define-lambda-callbacks-at-file-scope) | Enables deduplication |

| [No `try`/`catch`](#dont-use-trycatch-in-plan-resolvers) | Plan resolvers are declarative; use flow control steps |
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
| [No `try`/`catch`](#dont-use-trycatch-in-plan-resolvers) | Plan resolvers are declarative; use flow control steps |
| [No plan resolver `try`/`catch`](#dont-use-trycatch-in-plan-resolvers) | Plan resolvers are declarative; use `maskError` or flow control steps |

9 changes: 9 additions & 0 deletions grafast/website/grafast/plan-resolvers/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,15 @@ as steps to be populated at execution time for each request.

:::

:::tip[Best practices]

For recommendations on writing efficient and correct plan resolvers &mdash;
including deep argument extraction, when to use custom steps vs `lambda`,
and how to handle errors declaratively &mdash; see the
Comment on lines +31 to +32
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
including deep argument extraction, when to use custom steps vs `lambda`,
and how to handle errors declaratively &mdash; see the
including deep argument extraction; when to use custom steps, `loadOne()`, `loadMany()`, or `lambda()`;
and how to handle errors declaratively &mdash; see the

[Best practices](./best-practices.md) guide.

:::

## Field plan resolvers

```ts
Expand Down
16 changes: 16 additions & 0 deletions grafast/website/grafast/standard-steps/lambda.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,22 @@ not do this unless you are certain!

:::

## When to use something else

Before reaching for `lambda`, consider whether a better tool exists:

- **I/O or async work** &rarr; use [`loadOne()`](./loadOne.md) or
[`loadMany()`](./loadMany.md) which support batching
- **Non-trivial transforms that appear in multiple fields** &rarr; create a
[custom step class](../step-classes.mdx) with `deduplicate()` support
- **Side effects** &rarr; use [`sideEffect()`](/grafast/standard-steps/sideEffect)

`lambda` is best reserved for trivial, synchronous, pure transforms such as
string concatenation or simple arithmetic.

See [Plan resolver best practices](../plan-resolvers/best-practices.md) for
more guidance.

Comment on lines +83 to +98
Copy link
Member

Choose a reason for hiding this comment

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

We already have two notes on this page warning about lambda not batching; perhaps this should be folded into those?

## Warning: no batching!

**`lambda` is an escape hatch** that breaks you out of Gra*fast*'s batching;
Expand Down