diff --git a/.changeset/19920-exported-types-family-close.md b/.changeset/19920-exported-types-family-close.md new file mode 100644 index 00000000000..a584e186601 --- /dev/null +++ b/.changeset/19920-exported-types-family-close.md @@ -0,0 +1,20 @@ +--- +'@objectstack/spec': minor +--- + +fix(spec): `ApiError.code` and a flattened list overlay's legacy `options` bag carry the shapes their doors accept (#19920) + +Clause-②: no (narrowing) + +**BREAKING for TypeScript code that annotates with `ApiError`, with any response type built on `BaseResponseSchema` (`BaseResponse`, `BatchUpdateResponse`, `SessionResponse`, the metadata, package, storage, analytics and automation response types, and the rest), with `ViewMetadata`, `ViewMetadataParsed`, `AssembledViewArtifact` or `AssembledViewArtifactParsed`, or with the input type of a schema returned by `makeApiErrorSchema`**: a narrowing of published TYPES, landing in the launch window as `minor` (the lockstep convention: the bump level is not the carrier, this banner and the disposition below are). The runtime accept set does not move at all: no schema's parse, no value and no export changes, and no export is added. + +Two places in the published types were wider than the doors that judge the same bodies, so values those doors refuse type-checked: + +- `ApiError.code` (the INPUT type of `ApiErrorSchema`): FROM `unknown` TO `ErrorCode`, the vocabulary the schema parses against (`StandardErrorCode` and the registered ledger codes). `ErrorCode` was cast to `z.ZodType` with its output type only, and `z.ZodType`'s input type defaults to `unknown`, so `{ code: 42, message: 'x' }` compiled as an `ApiError` while the schema refuses it at `code`. The same `code` narrows in the `error` of every response envelope built on `BaseResponseSchema`, and in each `ApiError` row of a batch result. `makeApiErrorSchema(codes)` had the same cast for a caller-supplied vocabulary: its schema's input `code` is now the standard catalogue plus `codes`, where it was `unknown`. The parsed types (`ApiErrorParsed`, the `…Parsed` response types) do not move: their `code` was already typed. +- A flattened list overlay's legacy `options` bag: FROM a string-keyed record of `unknown` TO one optional entry per list kind that has a block (`calendar`, `chart`, `gallery`, `gantt`, `kanban`, `map`, `timeline`, `tree`), each entry that kind's own block with every key optional. This holds on the list overlay member of `ViewMetadata`, `ViewMetadataParsed`, `AssembledViewArtifact` and `AssembledViewArtifactParsed`. `options: { foo: 1, kanban: 42 }` type-checked as all four while that member refuses both keys. + +**If your code stops compiling.** A value you annotated with one of these names is not the shape the door accepts: correct it, or type a value that is still unvalidated as `unknown` and let the schema's `safeParse` decide. An error `code` is a member of `ErrorCode` (or, for a `makeApiErrorSchema` schema, of the standard catalogue plus the codes you supplied); a producer whose own code is outside the vocabulary reports it on `declaredCode`, not `code`. An `options` bag carries only the per-kind blocks listed above, each judged key by key like the top-level block of the same kind; `grid` has no block, and its settings are top-level keys of the view. + +The declared types of `ErrorCode` and of `makeApiErrorSchema`'s `code` narrow with them, so `z.input` of each is typed where it was `unknown`. The types are the schemas' declared shapes, not their verdicts: refinements are not types, so each schema remains the only judge. + + diff --git a/packages/spec/src/api/api-error-code-type.test.ts b/packages/spec/src/api/api-error-code-type.test.ts new file mode 100644 index 00000000000..a99de99f69e --- /dev/null +++ b/packages/spec/src/api/api-error-code-type.test.ts @@ -0,0 +1,93 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#19920] `ApiError.code` is the code vocabulary `ApiErrorSchema` parses against, not `unknown`. + * + * `ErrorCode` was cast to `z.ZodType`. `z.ZodType` takes + * two type parameters, ``, and `Input` defaults to `unknown`, so the INPUT type of + * `ApiErrorSchema` typed `code` as `unknown`: `{ code: 42, message: 'x' }` compiled as an + * `ApiError`, and as the `error` of every response envelope built on `BaseResponseSchema`, while + * the schema refuses it at `code`. `makeApiErrorSchema` repeated the one-parameter cast for a + * caller-supplied vocabulary. Both casts now name both parameters. + * + * Two halves, judged by two programs (the `view-overlay-viewkind-type.test.ts` shape): + * + * - The TYPE half is judged by `tsc -p tsconfig.test.json` (the package's `typecheck` script, via + * `check:test-typecheck`), not by vitest. Each `@ts-expect-error` below asserts that its line + * does NOT compile. While `code` was `unknown` every one of them compiled, so each directive was + * unused: TS2578 in a file with no `test-typecheck-debt.json` entry, which reds the gate. + * - The RUNTIME half ties the type to the door: each body the type now refuses is refused by the + * schema at `code`, and each body it admits parses. + */ + +import { describe, it, expect } from 'vitest'; +import type { z } from 'zod'; +import { + ApiErrorSchema, + BaseResponseSchema, + makeApiErrorSchema, + type ApiError, + type ApiErrorParsed, + type BaseResponse, +} from './contract.zod'; +import { ErrorCode } from './error-code-ledger.zod'; + +type IsUnknown = unknown extends T ? true : false; + +// ── The input type of `code` is the vocabulary ──────────────────────────────────────────────── + +const codeIsTyped: [ + IsUnknown>, IsUnknown, IsUnknown, +] = [false, false, false]; +const standardCode: ApiError = { code: 'VALIDATION_ERROR', message: 'x' }; +const registeredCode: ApiError['code'] = 'INVALID_ARTIFACT_PACKAGES'; +// @ts-expect-error -- `code` is a vocabulary member, not a number. +const numericCode: ApiError = { code: 42, message: 'x' }; +// @ts-expect-error -- nor a string outside the vocabulary. +const inventedCode: ApiError = { code: 'NOT_A_REGISTERED_CODE', message: 'x' }; +// @ts-expect-error -- the same `code` reaches every envelope built on `BaseResponseSchema`. +const envelopeNumericCode: BaseResponse = { success: false, error: { code: 42, message: 'x' } }; +void [codeIsTyped, standardCode, registeredCode, numericCode, inventedCode, envelopeNumericCode]; + +// ── `makeApiErrorSchema`: standard catalogue ∪ the caller's codes ───────────────────────────── + +const AcmeApiErrorSchema = makeApiErrorSchema(['ACME_QUOTA_EXCEEDED'] as const); +type AcmeApiError = z.input; +const acmeCodeIsTyped: IsUnknown = false; +const acmeSupplied: AcmeApiError = { code: 'ACME_QUOTA_EXCEEDED', message: 'x' }; +const acmeStandard: AcmeApiError = { code: 'RECORD_NOT_FOUND', message: 'x' }; +// @ts-expect-error -- a code neither standard nor supplied. +const acmeUnsupplied: AcmeApiError = { code: 'ACME_NOT_SUPPLIED', message: 'x' }; +// @ts-expect-error -- nor a number. +const acmeNumeric: AcmeApiError = { code: 42, message: 'x' }; +void [acmeCodeIsTyped, acmeSupplied, acmeStandard, acmeUnsupplied, acmeNumeric]; + +/** The one issue a refused body carries, reduced to what the envelope contract names. */ +function refusalAt(result: z.ZodSafeParseResult): Array<{ code: string; path: PropertyKey[] }> { + expect(result.success).toBe(false); + return (result.error?.issues ?? []).map((issue) => ({ code: issue.code, path: issue.path })); +} + +describe('[#19920] ApiError.code is typed as the vocabulary its schema parses against', () => { + it('every body the type refuses is refused by the schema at `code`', () => { + expect(refusalAt(ApiErrorSchema.safeParse({ code: 42, message: 'x' }))).toEqual([ + { code: 'invalid_value', path: ['code'] }, + ]); + expect(refusalAt(ApiErrorSchema.safeParse({ code: 'NOT_A_REGISTERED_CODE', message: 'x' }))).toEqual([ + { code: 'invalid_value', path: ['code'] }, + ]); + expect( + refusalAt(BaseResponseSchema.safeParse({ success: false, error: { code: 42, message: 'x' } })), + ).toEqual([{ code: 'invalid_value', path: ['error', 'code'] }]); + expect(refusalAt(AcmeApiErrorSchema.safeParse({ code: 'ACME_NOT_SUPPLIED', message: 'x' }))).toEqual([ + { code: 'invalid_value', path: ['code'] }, + ]); + }); + + it('every body the type admits parses', () => { + expect(ApiErrorSchema.safeParse(standardCode).success).toBe(true); + expect(ApiErrorSchema.safeParse({ code: registeredCode, message: 'x' }).success).toBe(true); + expect(AcmeApiErrorSchema.safeParse(acmeSupplied).success).toBe(true); + expect(AcmeApiErrorSchema.safeParse(acmeStandard).success).toBe(true); + }); +}); diff --git a/packages/spec/src/api/contract.zod.ts b/packages/spec/src/api/contract.zod.ts index 6a7915f0a23..862667a8b0e 100644 --- a/packages/spec/src/api/contract.zod.ts +++ b/packages/spec/src/api/contract.zod.ts @@ -284,7 +284,9 @@ export function makeApiErrorSchema(extra return ApiErrorSchema.extend({ // The retired-spelling prescription rides this door too: `.options` above // carries the catalogue's members, not its error map (`retired-error-codes.ts`). - code: (z.enum(vocabulary as [string, ...string[]], { error: retiredStandardErrorCodeMessage }) as z.ZodType) + // [#19920] Both `z.ZodType` parameters are named, as on `ErrorCode`: naming + // the output alone left this schema's INPUT `code` typed `unknown`. + code: (z.enum(vocabulary as [string, ...string[]], { error: retiredStandardErrorCodeMessage }) as z.ZodType) .describe('Error code (StandardErrorCode ∪ the ledger this consumer registered)'), }); } diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index d8f2b58a011..4b551580a02 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -1390,6 +1390,21 @@ export const REGISTERED_ERROR_CODES: readonly RegisteredErrorCode[] = Object.fre * standard catalog ∪ registered extension codes. This is what * `ApiErrorSchema.code` parses against — an unregistered code is a schema * failure, not a new dialect. + * + * [#19920] The cast names BOTH of `z.ZodType`'s parameters, ``. + * The cast exists because the spread above erases the members to `string`. + * Naming only the output left `Input` at its default, `unknown`, so `ApiError` + * (the INPUT type of `ApiErrorSchema`) typed `code` as `unknown`: + * `{ code: 42, message: 'x' }` compiled as an `ApiError` while this schema + * refuses it. An enum's input is its output, so both parameters are the same + * union. + * + * Both are spelled with the {@link ErrorCode} type alias, never the union + * written out: declaration emit prints an inline union literal by literal at + * every schema that embeds this one (78 sites in the `api` entry), while an + * alias it prints by name. Measured over the built declarations: with the + * union inline in both parameters they grew by 3.36 MB (+11%); with the alias + * they shrink by 3.32 MB, since the output half was printed inline before too. */ export const ErrorCode = z.enum( [...StandardErrorCode.options, ...REGISTERED_ERROR_CODES] as [string, ...string[]], @@ -1397,7 +1412,7 @@ export const ErrorCode = z.enum( // standard spelling would answer here with zod's bare enum message unless this // door passes the same prescription (`retired-error-codes.ts`). { error: retiredStandardErrorCodeMessage }, -) as z.ZodType; +) as z.ZodType; export type ErrorCode = StandardErrorCode | RegisteredErrorCode; diff --git a/packages/spec/src/ui/view-overlay-options-type.test.ts b/packages/spec/src/ui/view-overlay-options-type.test.ts new file mode 100644 index 00000000000..8e3be05aabf --- /dev/null +++ b/packages/spec/src/ui/view-overlay-options-type.test.ts @@ -0,0 +1,106 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#19920] The flattened list overlay's legacy `options` bag carries each kind's own block, with + * every key optional, not a string-keyed record of `unknown`. + * + * `listViewKindBlocks()` returned `Record`, so the bag's static type was + * `{ [x: string]: unknown }` on the list overlay member, and so on `ViewMetadata`, + * `ViewMetadataParsed`, `AssembledViewArtifact` and `AssembledViewArtifactParsed`: + * `options: { foo: 1, kanban: 42 }` type-checked while that member refuses both keys. Its return + * type is now derived by the same rule the loop runs (a value of the list shape's `type` enum that + * also names a block on the shape), with zod's own `.partial()` type per block. + * + * Two halves, judged by two programs (the `view-overlay-viewkind-type.test.ts` shape): + * + * - The TYPE half is judged by `tsc -p tsconfig.test.json` (the package's `typecheck` script, via + * `check:test-typecheck`), not by vitest. Each `@ts-expect-error` below asserts that its line + * does NOT compile. While the bag was a record of `unknown` every one of them compiled, so each + * directive was unused: TS2578 in a file with no `test-typecheck-debt.json` entry. + * - The RUNTIME half ties it to the doors: the runtime key set is the type's key set, each body + * the type refuses is refused by every door that judges it, and the partial underlay parses. + */ + +import { describe, it, expect } from 'vitest'; +import type { z } from 'zod'; +import { + VIEW_METADATA_MEMBERS, + ViewMetadataSchema, + type ViewMetadata, + type ViewMetadataParsed, +} from './view.zod'; +import { + AssembledViewArtifactSchema, + type AssembledViewArtifact, + type AssembledViewArtifactParsed, +} from './assembled-views.zod'; + +type ListOverlayIn = z.input; +type ListOverlayOut = z.output; +type OptionsIn = NonNullable; +type OptionsOut = NonNullable; + +/** The kinds that name a block on the list shape: its `type` enum minus `grid`. */ +const KIND_BLOCKS = ['calendar', 'chart', 'gallery', 'gantt', 'kanban', 'map', 'timeline', 'tree'] as const; +type KindBlock = (typeof KIND_BLOCKS)[number]; + +type Equal = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type NeverEntries = { [K in keyof T]-?: [NonNullable] extends [never] ? K : never }[keyof T]; + +// ── The bag's key set is the kind set, and no entry collapsed to `never` ───────────────────── + +const keySetIsTheKindSet: [Equal, Equal] = [true, true]; +const noEntryIsNever: [[NeverEntries] extends [never] ? true : false] = [true]; +void [keySetIsTheKindSet, noEntryIsNever]; + +// ── Each entry is the kind's own block with every key optional ─────────────────────────────── + +// `groupByField` is required on the kanban block itself; the underlay may leave it out. +const partialUnderlay: OptionsIn = { kanban: { summarizeField: 'amount' } }; +// @ts-expect-error -- `options.foo` is no kind. +const unknownKind: OptionsIn = { foo: 1 }; +// @ts-expect-error -- `grid` names no block. +const gridBlock: OptionsIn = { grid: {} }; +// @ts-expect-error -- a kind's entry is an object, not a number. +const numericBlock: OptionsIn = { kanban: 42 }; +// @ts-expect-error -- and each key has the block's own type. +const wrongKeyType: OptionsIn = { kanban: { groupByField: 42 } }; +// @ts-expect-error -- and the block is strict: a key it does not declare is refused. +const undeclaredKey: OptionsIn = { kanban: { nope: 1 } }; +void [partialUnderlay, unknownKind, gridBlock, numericBlock, wrongKeyType, undeclaredKey]; + +// ── The body the member refuses no longer type-checks as any union type ────────────────────── + +const typedBag: ViewMetadata = { object: 'crm_lead', viewKind: 'list', options: { kanban: { summarizeField: 'amount' } } }; +// @ts-expect-error -- a list overlay whose bag holds a number is no view body. +const numericBagMetadata: ViewMetadata = { object: 'crm_lead', viewKind: 'list', options: { kanban: 42 } }; +// @ts-expect-error -- nor a parsed one. +const numericBagParsedMetadata: ViewMetadataParsed = { object: 'crm_lead', viewKind: 'list', options: { kanban: 42 } }; +// @ts-expect-error -- nor a view artifact. +const numericBagArtifact: AssembledViewArtifact = { object: 'crm_lead', viewKind: 'list', options: { kanban: 42 } }; +// @ts-expect-error -- nor a parsed one. +const numericBagParsedArtifact: AssembledViewArtifactParsed = { object: 'crm_lead', viewKind: 'list', options: { kanban: 42 } }; +void [typedBag, numericBagMetadata, numericBagParsedMetadata, numericBagArtifact, numericBagParsedArtifact]; + +describe('[#19920] the list overlay options bag carries each kind block', () => { + it('the runtime key set is the kind set the type names', () => { + const bag = VIEW_METADATA_MEMBERS.listOverlay.shape.options.unwrap(); + expect(Object.keys(bag.shape).sort()).toEqual([...KIND_BLOCKS]); + }); + + it('a bag the type refuses is refused by every door that judges it', () => { + for (const options of [{ kanban: 42 }, { foo: 1 }, { kanban: { groupByField: 42 } }, { kanban: { nope: 1 } }]) { + const body = { object: 'crm_lead', viewKind: 'list', options }; + for (const door of [VIEW_METADATA_MEMBERS.listOverlay, ViewMetadataSchema, AssembledViewArtifactSchema]) { + expect(door.safeParse(body).success, JSON.stringify(options)).toBe(false); + } + } + }); + + it('the partial underlay the type admits parses at every door', () => { + const body = { object: 'crm_lead', viewKind: 'list', options: { kanban: { summarizeField: 'amount' } } }; + for (const door of [VIEW_METADATA_MEMBERS.listOverlay, ViewMetadataSchema, AssembledViewArtifactSchema]) { + expect(door.safeParse(body).success).toBe(true); + } + }); +}); diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index d9ae60ae74b..bedcd47ca47 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -5665,17 +5665,58 @@ const ViewContainerWireSchema = lazySchema(() => * the loud answer this derivation wants: a kind block that grows a cross-key * check forces a decision about how that check reads on a partial underlay, * instead of silently losing it. + * + * [#19920] The return type is {@link ListViewKindBlocks}, derived by the same + * rule at the type level. It was `Record`, which typed + * the bag as a string-keyed record of `unknown` on the list overlay member, and + * so on `ViewMetadata`, `ViewMetadataParsed`, `AssembledViewArtifact` and + * `AssembledViewArtifactParsed`: `options: { foo: 1, kanban: 42 }` type-checked + * while this member refuses it. The loop below is unchanged; the one assertion + * on its result states what the two derivations share, and + * `view-overlay-options-type.test.ts` pins the runtime key set to the type's. */ -function listViewKindBlocks(): Record { +function listViewKindBlocks(): ListViewKindBlocks { const shape = (ListViewShapeSchema as unknown as { shape: Record }).shape; const blocks: Record = {}; for (const kind of overlayTypeValues(ListViewShapeSchema)) { const block = shape[kind] as unknown as { unwrap?: () => { partial: () => z.ZodTypeAny } } | undefined; if (block?.unwrap) blocks[kind] = block.unwrap().partial().optional(); } - return blocks; + return blocks as ListViewKindBlocks; } +/** + * [#19920] What {@link listViewKindBlocks} builds for ONE kind, as a function + * so its return type is zod's own answer for `.unwrap().partial().optional()` + * rather than a hand-written copy of it. Only its type is read + * ({@link ListViewKindBlocks}); the loop above keeps its own duck-typed calls. + */ +function partialListViewKindBlock( + block: z.ZodOptional>, +) { + return block.unwrap().partial().optional(); +} + +type ListViewShapeFields = (typeof ListViewShapeSchema)['shape']; + +/** + * [#19920] The kinds that name a block, by {@link listViewKindBlocks}' own rule: + * a value of the shape's `type` enum that is also a key of the shape (`grid` + * names none). + */ +type ListViewKindBlockName = Extract, keyof ListViewShapeFields>; + +/** + * [#19920] The static type of {@link listViewKindBlocks}: per kind, the kind's + * own block with every key optional. A block that stopped being an optional + * object would read `never` here, and the pin test's never-check goes red. + */ +type ListViewKindBlocks = { + [K in ListViewKindBlockName]: ListViewShapeFields[K] extends z.ZodOptional> + ? ReturnType> + : never; +}; + /** * [#20051] The legacy `options` bag on a flattened LIST overlay, judged. *