Skip to content
Merged
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
25 changes: 16 additions & 9 deletions packages/errata/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,11 +119,14 @@ export interface ErrorClient<TCodes extends CodesRecord> {
Extract<CodesForTag<TCodes, TTag>, CodeOf<TCodes>>
>
/** Promise helper that returns a tuple without try/catch. */
safe: <T>(
promise: Promise<T>,
) => Promise<
[data: T, error: null] | [data: null, error: ErrataClientErrorForCodes<TCodes, ClientCode<TCodes>>]
>
safe: {
<T>(fn: () => T | Promise<T>): Promise<
[data: T, error: null] | [data: null, error: ErrataClientErrorForCodes<TCodes, ClientCode<TCodes>>]
>
<T>(promise: Promise<T>): Promise<
[data: T, error: null] | [data: null, error: ErrataClientErrorForCodes<TCodes, ClientCode<TCodes>>]
>
}
}

type InferCodes<T> = T extends ErrataInstance<infer TCodes>
Expand Down Expand Up @@ -327,20 +330,24 @@ export function createErrorClient<TServer extends ErrataInstance<any>>(
return (err.tags ?? []).includes(tag)
}

/** Promise helper returning a tuple without try/catch at call sites. */
const safe = async <T>(
promise: Promise<T>,
/** Promise/helper returning a tuple without try/catch at call sites. */
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

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

The comment has a grammatical issue. "Promise/helper" should be "Promise helper or function" to properly describe that this function accepts both promises and functions.

Suggested change
/** Promise/helper returning a tuple without try/catch at call sites. */
/** Promise helper or function returning a tuple without try/catch at call sites. */

Copilot uses AI. Check for mistakes.
const safe = (async <T>(
input: Promise<T> | (() => T | Promise<T>),
): Promise<
[data: T, error: null] | [data: null, error: ErrataClientErrorForCodes<TCodes, ClientBoundaryCode>]
> => {
const promise = typeof input === 'function'
? new Promise<T>(resolve => resolve((input as () => T | Promise<T>)()))
: input

try {
const data = await promise
return [data, null]
}
catch (err) {
return [null, ensure(err)]
}
}
}) as ErrorClient<TCodes>['safe']

return {
ErrataError: ErrataClientError,
Expand Down
23 changes: 15 additions & 8 deletions packages/errata/src/errata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,11 +121,14 @@ export interface ErrataInstance<TCodes extends CodesRecord> {
): BoundaryErrataError<TCodes, CodeOf<TCodes> | InternalCode>
}
/** Promise helper that returns a `[data, error]` tuple without try/catch. */
safe: <T>(
promise: Promise<T>,
) => Promise<
[data: T, error: null] | [data: null, error: BoundaryErrataError<TCodes, CodeOf<TCodes> | InternalCode>]
>
safe: {
<T>(fn: () => T | Promise<T>): Promise<
[data: T, error: null] | [data: null, error: BoundaryErrataError<TCodes, CodeOf<TCodes> | InternalCode>]
>
<T>(promise: Promise<T>): Promise<
[data: T, error: null] | [data: null, error: BoundaryErrataError<TCodes, CodeOf<TCodes> | InternalCode>]
>
}
/**
* Type-safe pattern check; supports exact codes, wildcard patterns (`'auth.*'`),
* and arrays of patterns. Returns a type guard narrowing the error type.
Expand Down Expand Up @@ -447,19 +450,23 @@ export function errata<
const ensure = ensureFn as ErrataInstance<AllCodes>['ensure']

/** Promise helper that returns a `[data, error]` tuple without try/catch. */
const safe = async <T>(
promise: Promise<T>,
const safe = (async <T>(
input: Promise<T> | (() => T | Promise<T>),
): Promise<
[data: T, error: null] | [data: null, error: BoundaryErrataError<AllCodes, BoundaryCode>]
> => {
const promise = typeof input === 'function'
? new Promise<T>(resolve => resolve((input as () => T | Promise<T>)()))
: input

try {
const data = await promise
return [data, null]
}
catch (err) {
return [null, ensure(err)]
}
}
}) as ErrataInstance<AllCodes>['safe']

/** Type-safe pattern check; supports exact codes, wildcard patterns, and arrays. */
const is = ((
Expand Down
9 changes: 9 additions & 0 deletions packages/errata/test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,15 @@ describe('client safe()', () => {
expectTypeOf(err.code).toEqualTypeOf<ErrorCode | InternalCode>()
}
})

it('captures synchronous throws from function input', async () => {
const [data, err] = await client.safe(() => {
throw new Error('boom')
})

expect(data).toBeNull()
expect(err?.code).toBe('errata.unknown_error')
})
Comment on lines +332 to +339
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

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

The test coverage for the new function input overload is incomplete. While synchronous throws are tested, there's no test for the case where a function returns a Promise (e.g., an async function or a function that returns Promise.resolve/reject). Consider adding a test case like:

it('handles async function that returns promise', async () => {
  const [data, err] = await client.safe(async () => {
    throw new Error('async boom')
  })
  expect(data).toBeNull()
  expect(err?.code).toBe('errata.unknown_error')
})

Also consider testing successful async function returns.

Copilot uses AI. Check for mistakes.
})

describe('client onUnknown hook', () => {
Expand Down
10 changes: 10 additions & 0 deletions packages/errata/test/errata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,16 @@ describe('errata basics', () => {
expectTypeOf(value).toEqualTypeOf<number>()
}
})

it('handles synchronous throw from function input', async () => {
const [value, err] = await errors.safe(() => {
throw new Error('boom')
})

expect(value).toBeNull()
expect(err).toBeInstanceOf(errors.ErrataError)
expect(err?.code).toBe('errata.unknown_error')
})
Comment on lines +213 to +221
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

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

The test coverage for the new function input overload is incomplete. While synchronous throws are tested, there's no test for the case where a function returns a Promise (e.g., an async function or a function that returns Promise.resolve/reject). Consider adding a test case like:

it('handles async function that returns promise', async () => {
  const [value, err] = await errors.safe(async () => {
    throw new Error('async boom')
  })
  expect(value).toBeNull()
  expect(err?.code).toBe('errata.unknown_error')
})

Also consider testing successful async function returns.

Copilot uses AI. Check for mistakes.
})

describe('onUnknown hook', () => {
Expand Down