Skip to content

Optional Values

By default, every field in the context shape is required. If an upstream task failed or was skipped, any task that depends on its value is skipped as well — this is the safe default.

But sometimes a task should run regardless of whether a particular value is available. A common example is an analytics tracker: it might be disabled in development or excluded from certain builds, yet the tasks that use it should still run normally.

This is what optional is for.

import { optional } from "@grlt-hub/app-compose"
const checkout = createTask({
name: "checkout",
run: {
context: {
analytics: optional(analytics.result), // analytics.result | undefined
user: user.result,
},
fn: (ctx) => {
ctx.analytics?.track("checkout_started")
console.log(`checkout for ${ctx.user.name}`)
},
},
})

Wrapping a field with optional changes its type from T to T | undefined. If the value isn’t available — because the upstream task failed, was skipped, or wasn’t included in the pipeline at all — the task still runs and receives undefined.


Check out this interactive example. Try removing analytics from the first stage to see checkout still run:

import { compose, createTask, optional } from "@grlt-hub/app-compose"
const user = createTask({
name: "user",
run: { fn: () => ({ name: "Bob" }) },
})
const analytics = createTask({
name: "analytics",
run: {
fn: () => ({
track: (event: string) => {
console.log(`[analytics] track: ${event}`)
},
}),
},
})
const checkout = createTask({
name: "checkout",
run: {
context: {
// 👇 analytics may or may not be in the pipeline
analytics: optional(analytics.result),
user: user.result,
},
fn: (ctx) => {
ctx.analytics?.track("checkout_started")
console.log(`checkout for ${ctx.user.name}`)
},
},
})
compose()
// 👇 Try removing `analytics` — checkout still runs
.stage({ steps: [analytics] })
.stage({ steps: [user] })
.stage({ steps: [checkout] })
.run()

Required fields in the shape still behave as before — if any of them is missing, the task is skipped. Only the fields explicitly wrapped with optional can be absent without affecting the task.

optional composes with map. When the source value is missing, map’s callback receives undefined — the task still runs, rather than being skipped:

const checkout = createTask({
name: "checkout",
run: {
context: map(optional(analytics.result), (tracker) => tracker?.isEnabled ?? false),
fn: (isTracked) => {
console.log(`checkout: tracking=${isTracked}`)
},
},
})

When a tag is bound to an optional value, its type must allow undefined — otherwise it won’t match the tasks that use it. optional works in bind for exactly this case: if the source task is absent, the tag carries undefined:

const analyticsTag = createTag<Analytics | undefined>({ name: "analytics" })
compose()
.stage({ steps: [analytics] })
.stage({
steps: [bind(analyticsTag, optional(analytics.result))],
})
.stage({ steps: [checkout] })
.run()