From 74a132da1f778344396d0d19040f45a63d12c371 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 11:13:11 +0000 Subject: [PATCH 1/4] fix(spec): type ApiError.code and the list overlay options bag ErrorCode and makeApiErrorSchema's code cast named only z.ZodType's output parameter, so ApiError.code (an input type) was unknown. Both now name Output and Input. listViewKindBlocks() returned Record, so the flattened list overlay's legacy options bag was a string-keyed record of unknown. Its return type is now a mapped type over the kinds that name a block, each entry zod's own partial type of the kind block. The runtime loop is unchanged. Two type-pin files hold both. Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW Co-authored-by: Claude --- .../spec/src/api/api-error-code-type.test.ts | 93 +++++++++++++++ packages/spec/src/api/contract.zod.ts | 4 +- .../spec/src/api/error-code-ledger.zod.ts | 10 +- .../src/ui/view-overlay-options-type.test.ts | 106 ++++++++++++++++++ packages/spec/src/ui/view.zod.ts | 45 +++++++- 5 files changed, 254 insertions(+), 4 deletions(-) create mode 100644 packages/spec/src/api/api-error-code-type.test.ts create mode 100644 packages/spec/src/ui/view-overlay-options-type.test.ts 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..5f0cd1840c3 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -1390,6 +1390,14 @@ 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`; it + * is not there to dodge declaration size. 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. */ export const ErrorCode = z.enum( [...StandardErrorCode.options, ...REGISTERED_ERROR_CODES] as [string, ...string[]], @@ -1397,7 +1405,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. * From 539295c9e621d60a7bcefcc764972acb55277cbe Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 11:31:47 +0000 Subject: [PATCH 2/4] fix(spec): spell ErrorCode's cast with the ErrorCode alias Naming Input as the inline union doubled the union's prints at every schema embedding ApiErrorSchema (+3.36 MB of declarations). The alias is printed by name, so the built declarations shrink by 3.32 MB instead. Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW Co-authored-by: Claude --- .../spec/src/api/error-code-ledger.zod.ts | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index 5f0cd1840c3..4b551580a02 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -1392,12 +1392,19 @@ export const REGISTERED_ERROR_CODES: readonly RegisteredErrorCode[] = Object.fre * 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`; it - * is not there to dodge declaration size. 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. + * 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[]], @@ -1405,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; From 4358d1a334193101953aff832b6f9abd84c82482 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 11:50:23 +0000 Subject: [PATCH 3/4] chore(changeset): spec minor for the ApiError.code and options bag narrowing Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW Co-authored-by: Claude --- .../19920-exported-types-family-close.md | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .changeset/19920-exported-types-family-close.md diff --git a/.changeset/19920-exported-types-family-close.md b/.changeset/19920-exported-types-family-close.md new file mode 100644 index 00000000000..ff60143f09e --- /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-②: yes (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. + + From b26d6506bfd199bbc04421aae8a9b38081ef05bc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 13:45:41 +0000 Subject: [PATCH 4/4] =?UTF-8?q?docs(changeset):=20Clause-=E2=91=A1=20is=20?= =?UTF-8?q?no=20(narrowing)=20=E2=80=94=20the=20diff=20widens=20nothing=20?= =?UTF-8?q?(#19920)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW Co-authored-by: Claude --- .changeset/19920-exported-types-family-close.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/19920-exported-types-family-close.md b/.changeset/19920-exported-types-family-close.md index ff60143f09e..a584e186601 100644 --- a/.changeset/19920-exported-types-family-close.md +++ b/.changeset/19920-exported-types-family-close.md @@ -4,7 +4,7 @@ fix(spec): `ApiError.code` and a flattened list overlay's legacy `options` bag carry the shapes their doors accept (#19920) -Clause-②: yes (narrowing) +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.