From 08baf56f328ce4b6814a7f90089c0b3398d8bbfe Mon Sep 17 00:00:00 2001 From: Ben Holmes Date: Tue, 9 Jul 2024 15:54:49 -0400 Subject: [PATCH] Actions: expand `isInputError` to accept `unknown` (#11439) * feat: allow type `unknown` on `isInputError` * chore: move ErrorInferenceObject to internal utils * chore: changeset * deps: expect-type * feat: first types test * chore: add types test to general test command * refactor: use describe and it for organization --- .changeset/nasty-poems-juggle.md | 18 +++++++++++ package.json | 1 + packages/astro/package.json | 4 ++- packages/astro/src/actions/runtime/utils.ts | 11 +++++++ .../src/actions/runtime/virtual/server.ts | 10 ++---- .../src/actions/runtime/virtual/shared.ts | 8 +++-- packages/astro/test/types/is-input-error.ts | 31 +++++++++++++++++++ packages/astro/tsconfig.tests.json | 9 ++++++ pnpm-lock.yaml | 10 ++++++ 9 files changed, 90 insertions(+), 12 deletions(-) create mode 100644 .changeset/nasty-poems-juggle.md create mode 100644 packages/astro/test/types/is-input-error.ts create mode 100644 packages/astro/tsconfig.tests.json diff --git a/.changeset/nasty-poems-juggle.md b/.changeset/nasty-poems-juggle.md new file mode 100644 index 0000000000..74e1b176d0 --- /dev/null +++ b/.changeset/nasty-poems-juggle.md @@ -0,0 +1,18 @@ +--- +'astro': patch +--- + +Expands the `isInputError()` utility from `astro:actions` to accept errors of any type. This should now allow type narrowing from a try / catch block. + +```ts +// example.ts +import { actions, isInputError } from 'astro:actions'; + +try { + await actions.like(new FormData()); +} catch (error) { + if (isInputError(error)) { + console.log(error.fields); + } +} +``` diff --git a/package.json b/package.json index 4d477912b2..e99e740da0 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "test:citgm": "pnpm -r --filter=astro test", "test:match": "cd packages/astro && pnpm run test:match", "test:unit": "cd packages/astro && pnpm run test:unit", + "test:types": "cd packages/astro && pnpm run test:types", "test:unit:match": "cd packages/astro && pnpm run test:unit:match", "test:smoke": "pnpm test:smoke:example && pnpm test:smoke:docs", "test:smoke:example": "turbo run build --concurrency=100% --filter=\"@example/*\"", diff --git a/packages/astro/package.json b/packages/astro/package.json index 164d155a70..c66cb301e3 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -114,12 +114,13 @@ "build:ci": "pnpm run prebuild && astro-scripts build \"src/**/*.{ts,js}\" && pnpm run postbuild", "dev": "astro-scripts dev --copy-wasm --prebuild \"src/runtime/server/astro-island.ts\" --prebuild \"src/runtime/client/{idle,load,media,only,visible}.ts\" \"src/**/*.{ts,js}\"", "postbuild": "astro-scripts copy \"src/**/*.astro\" && astro-scripts copy \"src/**/*.wasm\"", - "test": "pnpm run test:node", + "test": "pnpm run test:node && pnpm run test:types", "test:match": "pnpm run test:node --match", "test:e2e": "pnpm test:e2e:chrome && pnpm test:e2e:firefox", "test:e2e:match": "playwright test -g", "test:e2e:chrome": "playwright test", "test:e2e:firefox": "playwright test --config playwright.firefox.config.js", + "test:types": "tsc --project tsconfig.tests.json", "test:node": "astro-scripts test \"test/**/*.test.js\"" }, "dependencies": { @@ -215,6 +216,7 @@ "astro-scripts": "workspace:*", "cheerio": "1.0.0-rc.12", "eol": "^0.9.1", + "expect-type": "^0.19.0", "mdast-util-mdx": "^3.0.0", "mdast-util-mdx-jsx": "^3.1.2", "memfs": "^4.9.3", diff --git a/packages/astro/src/actions/runtime/utils.ts b/packages/astro/src/actions/runtime/utils.ts index eac9b92cf9..02961144b4 100644 --- a/packages/astro/src/actions/runtime/utils.ts +++ b/packages/astro/src/actions/runtime/utils.ts @@ -31,3 +31,14 @@ export async function getAction( } return actionLookup; } + +/** + * Used to preserve the input schema type in the error object. + * This allows for type inference on the `fields` property + * when type narrowed to an `ActionInputError`. + * + * Example: Action has an input schema of `{ name: z.string() }`. + * When calling the action and checking `isInputError(result.error)`, + * `result.error.fields` will be typed with the `name` field. + */ +export type ErrorInferenceObject = Record; diff --git a/packages/astro/src/actions/runtime/virtual/server.ts b/packages/astro/src/actions/runtime/virtual/server.ts index ce4d5f6966..4d3745a687 100644 --- a/packages/astro/src/actions/runtime/virtual/server.ts +++ b/packages/astro/src/actions/runtime/virtual/server.ts @@ -1,13 +1,7 @@ import { z } from 'zod'; import { type ActionAPIContext, getApiContext as _getApiContext } from '../store.js'; -import { type MaybePromise } from '../utils.js'; -import { - ActionError, - ActionInputError, - type ErrorInferenceObject, - type SafeResult, - callSafely, -} from './shared.js'; +import type { ErrorInferenceObject, MaybePromise } from '../utils.js'; +import { ActionError, ActionInputError, type SafeResult, callSafely } from './shared.js'; export * from './shared.js'; diff --git a/packages/astro/src/actions/runtime/virtual/shared.ts b/packages/astro/src/actions/runtime/virtual/shared.ts index 420682aad4..94a22f7ca3 100644 --- a/packages/astro/src/actions/runtime/virtual/shared.ts +++ b/packages/astro/src/actions/runtime/virtual/shared.ts @@ -1,5 +1,5 @@ import type { z } from 'zod'; -import type { MaybePromise } from '../utils.js'; +import type { ErrorInferenceObject, MaybePromise } from '../utils.js'; type ActionErrorCode = | 'BAD_REQUEST' @@ -40,8 +40,6 @@ const statusToCodeMap: Record = Object.entries(codeToSt {} ); -export type ErrorInferenceObject = Record; - export class ActionError extends Error { type = 'AstroActionError'; code: ActionErrorCode = 'INTERNAL_SERVER_ERROR'; @@ -85,6 +83,10 @@ export class ActionError export function isInputError( error?: ActionError +): error is ActionInputError; +export function isInputError(error?: unknown): error is ActionInputError; +export function isInputError( + error?: unknown | ActionError ): error is ActionInputError { return error instanceof ActionInputError; } diff --git a/packages/astro/test/types/is-input-error.ts b/packages/astro/test/types/is-input-error.ts new file mode 100644 index 0000000000..ba0f7c0bc4 --- /dev/null +++ b/packages/astro/test/types/is-input-error.ts @@ -0,0 +1,31 @@ +import { expectTypeOf } from 'expect-type'; +import { isInputError, defineAction } from '../../dist/actions/runtime/virtual/server.js'; +import { z } from '../../zod.mjs'; +import { describe, it } from 'node:test'; + +const exampleAction = defineAction({ + input: z.object({ + name: z.string(), + }), + handler: () => {}, +}); + +const result = await exampleAction.safe({ name: 'Alice' }); + +describe('isInputError', () => { + it('isInputError narrows unknown error types', async () => { + try { + await exampleAction({ name: 'Alice' }); + } catch (e) { + if (isInputError(e)) { + expectTypeOf(e.fields).toEqualTypeOf>(); + } + } + }); + + it('`isInputError` preserves `fields` object type for ActionError objects', async () => { + if (isInputError(result.error)) { + expectTypeOf(result.error.fields).toEqualTypeOf<{ name?: string[] }>(); + } + }); +}); diff --git a/packages/astro/tsconfig.tests.json b/packages/astro/tsconfig.tests.json new file mode 100644 index 0000000000..1178731fa2 --- /dev/null +++ b/packages/astro/tsconfig.tests.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["test/types"], + "compilerOptions": { + "allowJs": true, + "emitDeclarationOnly": false, + "noEmit": true, + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e5e9bf3dda..934cd36fcb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -794,6 +794,9 @@ importers: eol: specifier: ^0.9.1 version: 0.9.1 + expect-type: + specifier: ^0.19.0 + version: 0.19.0 mdast-util-mdx: specifier: ^3.0.0 version: 3.0.0 @@ -8654,6 +8657,10 @@ packages: resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} engines: {node: '>=16.17'} + expect-type@0.19.0: + resolution: {integrity: sha512-piv9wz3IrAG4Wnk2A+n2VRCHieAyOSxrRLU872Xo6nyn39kYXKDALk4OcqnvLRnFvkz659CnWC8MWZLuuQnoqg==} + engines: {node: '>=12.0.0'} + express@4.19.2: resolution: {integrity: sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==} engines: {node: '>= 0.10.0'} @@ -9308,6 +9315,7 @@ packages: libsql@0.3.12: resolution: {integrity: sha512-to30hj8O3DjS97wpbKN6ERZ8k66MN1IaOfFLR6oHqd25GMiPJ/ZX0VaZ7w+TsPmxcFS3p71qArj/hiedCyvXCg==} + cpu: [x64, arm64, wasm32] os: [darwin, linux, win32] lilconfig@2.1.0: @@ -14704,6 +14712,8 @@ snapshots: signal-exit: 4.1.0 strip-final-newline: 3.0.0 + expect-type@0.19.0: {} + express@4.19.2: dependencies: accepts: 1.3.8