Skip to content

Commit cf55914

Browse files
fix(spec): type ApiError.code and the list overlay options bag (#20448)
Fixes #19920 Clause-②: no (narrowing) This PR takes items 1 and 3 of the remainder that seat 4's release on #19920 (comment 5865019059) names: `ApiError.code` and the flattened list overlay's legacy `options` bag. Item 2, `ViewFilterRule.operator`, is not changed: its input type is a contract choice, so it is analysed as a fork (below) for the seat to take to triage. ## What changed Only types change. No schema's parse, no value, and no export moves; no export is added. The FROM column was read by a compiler-API census at the base `0283cb924` and by probes against the source; the TO column is also probed against the built `dist`. | item | FROM | TO | |:--|:--|:--| | 1. `ApiError.code` (`api/error-code-ledger.zod.ts`, `api/contract.zod.ts`) | `unknown`. `ErrorCode` was cast to `z.ZodType` naming only its OUTPUT type parameter, and `z.ZodType`'s INPUT parameter defaults to `unknown`, so the input type of `ApiErrorSchema` typed `code` as `unknown`: `{ code: 42, message: 'x' }` compiled as an `ApiError` while the schema refuses it at `code`. The same `unknown` reached the `error.code` of every response type built on `BaseResponseSchema` (58 input aliases, measured) and each `ApiError` row of a batch result. `makeApiErrorSchema` repeated the one-parameter cast for a caller-supplied vocabulary. | `ErrorCode`: the cast names both parameters, each spelled with the existing `ErrorCode` type alias. `makeApiErrorSchema`: both parameters named, the standard catalogue plus the caller's codes. The `…Parsed` types do not move: their `code` was already typed. | | 3. The list overlay's `options` bag (`ui/view.zod.ts`) | A string-keyed record of `unknown`, on the list overlay member and so on `ViewMetadata`, `ViewMetadataParsed`, `AssembledViewArtifact` and `AssembledViewArtifactParsed`, because `listViewKindBlocks()` returned a record of string to `z.ZodTypeAny`. `options: { foo: 1, kanban: 42 }` type-checked as all four while the member refuses both keys. | One optional entry per list kind that names a block (`calendar`, `chart`, `gallery`, `gantt`, `kanban`, `map`, `timeline`, `tree`), each the kind's own block with every key optional. The return type is a mapped type derived by the function's own rule (a value of the list shape's `type` enum that is also a key of the shape), each entry typed by zod's own `.partial()` answer through a typed helper, never a hand-written copy. The runtime loop is byte-identical; one assertion on its result states what the two derivations share, and the new pin file holds the runtime key set equal to the type's. | ### A change beyond the order's route, forced by a measurement The dispatch suggested dropping `makeApiErrorSchema`'s cast. It stays: its vocabulary is caller-supplied and spread into a `string[]`, so the cast is what carries the caller's codes into the type; dropping it would also change the returned schema class (to `ZodEnum`), a public-type change beyond this item. The defect was the missing input parameter, and that is what changed. The `ErrorCode` cast exists because the spread erases the members to `string`, not to dodge declaration size (mechanism assumption A1). But naming the input parameter DID hit declaration size, measured, and that fixed the spelling: - **First spelling, inline union in both parameters** (`74a132da1`): the built declarations grew 30,647,033 to 34,006,427 B (+3.36 MB, +11.0%); `api/index.d.ts` alone +1,262,450 B. Declaration emit prints an inline union literal by literal wherever a schema embeds `ApiErrorSchema` (78 sites in the `api` entry), and the input parameter doubled those prints. - **Landed spelling, the `ErrorCode` alias** (`539295c9e`): the emitter prints the alias by name, including for the output half the base already printed inline. The declarations SHRINK instead (table below). The bundler emits one new shared chunk, `error-code-ledger.zod` (31,949 B `.d.ts`, 31,950 B `.d.mts`), for the name to be imported from. ## Measurements (spec build, base `0283cb924` against head code `539295c9e`) - **TS7056**: 0 in every spec build of this round (base, `74a132da1`, `539295c9e`). - **Declaration files**: 128 at base, 130 at head; the build's own `check-dts-references` resolves 394/394 relative references across the 130 (382/382 across 128 at base). | declaration file | base | head | delta | |:--|--:|--:|--:| | `api/index.d.ts` (`.d.mts` the same, within 2 B) | 2,532,113 | 1,274,615 | -1,257,498 | | `automation-api.zod` chunk `.d.ts` (and `.d.mts`) | 535,440 | 193,663 | -341,777 | | `api-assembled/index.d.ts` (and `.d.mts`) | 184,582 | 86,529 | -98,053 | | `contracts/index.d.ts` (and `.d.mts`) | 505,045 | 505,100 | +55 | | `view.zod` chunk `.d.ts` (and `.d.mts`) — item 3 | 498,393 | 505,886 | +7,493 | | `error-code-ledger.zod` chunk, new (`.d.ts`) | 0 | 31,949 | +31,949 | | all `.d.ts` / `.d.mts` files | 30,647,033 | 27,331,377 | -3,315,656 (-10.82%) | - **Item 3 against the order's size rule** (A2: implement only if within the same order as PR #20369's remainder 5, +2,729 B per chunk, 0 TS7056): item 3 moves only the `view.zod` chunk, +7,493 B per chunk (2.7 times that figure, the same order of magnitude, +1.5% of the chunk), 0 TS7056, no `any` anywhere (`check:exported-any` green). Taken on that reading; the ratio is stated so the seat can hold the rule to a tighter reading if it meant one. ## Reverse verification (from committed state `539295c9e`, on disk, through `scripts/ablation-replace.mjs`) Each pin file compiled under `tsconfig.test.json`'s options. The pins import `./contract.zod` / `./view.zod` relatively, so the subject is `src` and no `dist` is on the resolution path. | leg | reverted to (the base spelling) | pin file | result | |:--|:--|:--|:--| | control | nothing | all three pin files | 0 diagnostics | | A | `ErrorCode` cast naming the output parameter only | `api/api-error-code-type.test.ts` | 2 x TS2322, 3 x TS2578 | | B | `makeApiErrorSchema`'s cast naming the output parameter only | `api/api-error-code-type.test.ts` | 1 x TS2322, 2 x TS2578 | | C | `listViewKindBlocks()` returning a record of string to `z.ZodTypeAny` | `ui/view-overlay-options-type.test.ts` | 2 x TS2322, 9 x TS2578 | Every leg: the tool reports the anchor hit once and the mutation landed (blob changed), then the restore proven (blob equals the HEAD blob, `git diff HEAD` empty); an independent `git hash-object` check of all three files after the legs matches HEAD, and `git status --porcelain` is empty. ## Tests and gates Code is identical at `539295c9e` and `4358d1a33` (`4358d1a33` adds the changeset only). - **Spec**: build exit 0 (TS7056 x0); `typecheck` exit 0, `check:test-typecheck: OK — ... 53 file(s) / 251 error(s) / 138 pinned signature(s) held`, and `--listFilesOnly` puts both new pin files in its 540-test-file program; `vitest run --project local` at `4358d1a33`: 565 files passed, 16,604 tests passed, 1 todo; `check:generated`: all 15 generated artifacts up to date, with no tracked file moved by any build. - **Consumers** (after building spec and the 12-package closure of `metadata-protocol`): `@objectstack/metadata-protocol` typecheck exit 0 (192 test files in its program) and tests 189 files passed, 3 skipped, 2,745 tests passed, 19 skipped; `@objectstack/types` typecheck exit 0 (23 of 23 test files in its program) and tests 22 files, 685 passed; `@objectstack/client` `tsc --noEmit` over `src` exit 0. - **Probe against the built `dist`**, from a consumer program importing `dist/api` and `dist/ui`: 0 diagnostics, where every `@ts-expect-error` (a numeric `code` on `ApiError`, an invented one on `BaseResponse`, a numeric `options.kanban` on `ViewMetadata`, an unknown kind on `AssembledViewArtifact`) is consumed and a tuple compiles only if `ApiError.code` is neither `unknown` nor `any`. - **Gates**: `dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `4358d1a33` derived 86 commands (the dispatch's 75 plus 11); all 86 run, exit codes written to disk first: 83 exit 0 (`check:lean-entry-closure` after building `objectql`), 2 exit 3 PREREQUISITE NOT MET (`check:dual-build-cjs-loads`, `check:type-check-debt`: both need the whole-packages build). `--ran`: 86 derived, 84 run, 2 NOT-MEASURED, 0 UNRUN. Readings of note: `check-adr-0087-registration` reads `[BREAKING+clause-②-narrowing] not-required (no-migration-prescription)`; `check:api-surface` "public API surface + factory signatures unchanged"; `check:exported-any` "no exported type resolves to any: 2384 types + 1446 schemas across 18 entry points"; `check-empty-changeset` exit 0. - **Lint, narrowed and proven**: `eslint --no-inline-config --format json` over the 5 changed `.ts` files: 0 errors, 0 warnings; the changeset is outside eslint's configuration. `eslint.config.mjs`:327 enables no type-aware linting for any file, so this diff cannot move an untouched file's verdict. Repo-wide lint is CI's. - **NOT MEASURED, left to CI**: spec `test:repo` (it held the verify lock for the whole foreground window, about 595 s, without finishing, twice); the `@objectstack/client` test-layer typecheck (its 12 dev dependencies include `runtime` and `rest`, a 33-package build); the two exit-3 gates above; the objectui and cloud builds. ## Consumer census - **Item 1 in this repo.** 116 exported spec aliases carry `ApiErrorSchema`'s shape (58 input names; their `…Parsed` twins were already typed), found by walking each alias's properties, arrays and union members. Outside spec, code names them in `@objectstack/client` (return annotations, `as unknown as` casts and `['data']` reads), `@objectstack/metadata-protocol` (`toRowApiError`'s cast from `any` after a `safeParse` guard, and `as BatchUpdateResponse` casts) and `@objectstack/types` (`Pick` of `ApiError`'s optional fields, not `code`). All three typechecks are green above; neither named consumer file needed an edit. - **Item 3 in this repo.** Outside spec, `ViewMetadataSchema` and `AssembledViewArtifactSchema` are called with `safeParse` in `objectql`, `rest` and `metadata-protocol` tests and `objectql`'s `engine.ts`; both schemas' own static types are the erased unions, so no typed `options` read exists outside spec. - **objectui at the pin `f8a9d0fb`.** `ApiError` appears only as `Pick` of `userMessage` (two files); none of the four view types is named. Neither narrowing reaches it (from reading, not compiling). - **cloud**: no checkout in this container, NOT MEASURED. ## Item 2, `ViewFilterRule.operator`: not changed, a fork for triage `operator` is `z.preprocess(normalizeFilterOperator, z.enum(VIEW_FILTER_OPERATORS))`. zod types a preprocess's input as its function's parameter type, and `normalizeFilterOperator` takes `unknown`, so `{ field: 'status', operator: 42 }` compiles as a `ViewFilterRule` (and as a rule on every carrier: `ListView.filter`, tab filters, `Page.filterBy`) while the door refuses it. Who writes the legacy spellings the fold accepts, measured: - `examples/`: 0 legacy spellings on a view-filter carrier, 19 canonical ones in 8 files. (The one legacy-looking hit, `operator: 'ne'` in `app-showcase`'s `invoice.object.ts`, is a field's `lookupFilters`, a separate closed dialect.) - In-repo non-test code: 0 (every other hit is another dialect: lookup filters, auth `where`, skill trigger conditions, analytics). - objectui at the pin `f8a9d0fb`: the filter builder emits camelCase ids (13 of its 22 option values are alias-table keys: `notEquals`, `greaterThan`, `notIn`, `isNull`, …). Its two producers typed against spec's `ViewFilterRule` (`viewFilterFold.ts`, `ObjectDataPage.tsx`) fold through `normalizeFilterOperator` before typing, so the canonical id is what reaches the type. objectui at `9f0c84a44` (its current head) emits the 20 canonical ids only. - Stored `sys_metadata` rows: the alias table exists for them; they are read through the runtime parse, whose input is `unknown` whatever the type says. The three options, the four axes and the recommendation are in the `os-dev-report` on #19920 (`open_questions`). In short: A, canonical enum only (type the preprocess function's parameter; the runtime fold is untouched); B, the enum plus the alias-table spellings (needs the table's keys typed as literals, and still cannot express the case-folded variants the fold also accepts); C, leave `unknown` with a declared reason. The recommendation is A. ## What stays on #19920 A compiler-API census of the 2,337 non-generic exported aliases of `packages/spec/src` (tests excluded, 11 generic skipped), with an injected control module that must read lit (it did, at base and head): - **Alias level**: 6 aliases resolve to `unknown` at base and at head, and none belongs to this family: `FlowValueSlot`, `AssignmentValue` and their `Parsed` (value slots), `GetPublishedMetaItemResponse` and its `Parsed` (opaque by ruling). - **Top-level keys**: 194 at base, 193 at head; the one that left is `ApiError.code`, and none entered. The only family site left is `ViewFilterRule.operator` (item 2). Every other key the census reads is declared `z.unknown()` / `z.any()` (the door accepts anything, so the type is honest), a third-party or zod type, a service map or a fixture; `GetMetaItemLayeredResponse.code` and `ViewMetadata.defaults` were checked by hand and are both declared `z.unknown()`. - **Index signatures one level below a top-level key**: 391 at base, 387 at head; the four that left are `options` on `ViewMetadata`, `ViewMetadataParsed`, `AssembledViewArtifact` and `AssembledViewArtifactParsed`, and none entered. Blind spot, declared: deeper nesting is not walked. ## Clause-② Line 2 and the changeset (`b26d6506b`) both read `Clause-②: no (narrowing)`. The changeset keeps its BREAKING banner and the ADR-0087 marker `not-required (no-migration-prescription)`, so `check-adr-0087-registration` still reads the narrowing. The value is `no` because this diff adds no export and moves no accept set; it only narrows published types. That is the PR #19919 / PR #20260 shape for this defect class. ## Acceptance notes - `makeApiErrorSchema`'s generic return type still prints the standard catalogue inline: 4 prints in its one declaration (a 4,311 B line in the emitted `contract.zod` declaration), where the base printed 2. A local generic alias would name it; not done here, being one bounded declaration. - A field's `lookupFilters` is its own closed operator dialect (`eq`, `ne`, `gt`, `lt`, `gte`, `lte`, `contains`, `in`, `notIn`), whose members are spellings `ViewFilterRule` treats as deprecated aliases. Both are enforced; noted for item 2's triage, not filed. - The two new pin files follow the two-program shape of `view-overlay-viewkind-type.test.ts`: tsc judges the type half, vitest the runtime half; the refusal cases assert the issue `code` and `path`, not a bare failure. Line 1 was changed from the partial-landing marker to this closing keyword by the `domain:spec` seat 1 (`session_01B3TqpoQbTAfG7G74GMDWNW`): item 2 (`ViewFilterRule.operator`'s input type) now has its own card, #20450, for triage, which is the dispatch order's A4 condition for closing #19920 with this PR. Line 2 and the `## Clause-②` section were amended by the same seat after the at-tier record 5871015216 found the value `yes` wrong for this diff; the changeset line moved with them in `b26d6506b`, and the claim on #19920 was amended in place. The stale `Part of` paragraph was removed. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 35e549b commit cf55914

6 files changed

Lines changed: 281 additions & 4 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
fix(spec): `ApiError.code` and a flattened list overlay's legacy `options` bag carry the shapes their doors accept (#19920)
6+
7+
Clause-②: no (narrowing)
8+
9+
**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.
10+
11+
Two places in the published types were wider than the doors that judge the same bodies, so values those doors refuse type-checked:
12+
13+
- `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.
14+
- 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.
15+
16+
**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.
17+
18+
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.
19+
20+
<!-- adr-0087: not-required (no-migration-prescription) Nothing an author writes moves — no spec key, no export and no stored row changes, and every runtime accept set is unchanged, so `objectstack migrate meta` has nothing to reach — and only TypeScript annotations narrow, whose channel is the consumer's compiler. -->
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#19920] `ApiError.code` is the code vocabulary `ApiErrorSchema` parses against, not `unknown`.
5+
*
6+
* `ErrorCode` was cast to `z.ZodType<StandardErrorCode | RegisteredErrorCode>`. `z.ZodType` takes
7+
* two type parameters, `<Output, Input>`, and `Input` defaults to `unknown`, so the INPUT type of
8+
* `ApiErrorSchema` typed `code` as `unknown`: `{ code: 42, message: 'x' }` compiled as an
9+
* `ApiError`, and as the `error` of every response envelope built on `BaseResponseSchema`, while
10+
* the schema refuses it at `code`. `makeApiErrorSchema` repeated the one-parameter cast for a
11+
* caller-supplied vocabulary. Both casts now name both parameters.
12+
*
13+
* Two halves, judged by two programs (the `view-overlay-viewkind-type.test.ts` shape):
14+
*
15+
* - The TYPE half is judged by `tsc -p tsconfig.test.json` (the package's `typecheck` script, via
16+
* `check:test-typecheck`), not by vitest. Each `@ts-expect-error` below asserts that its line
17+
* does NOT compile. While `code` was `unknown` every one of them compiled, so each directive was
18+
* unused: TS2578 in a file with no `test-typecheck-debt.json` entry, which reds the gate.
19+
* - The RUNTIME half ties the type to the door: each body the type now refuses is refused by the
20+
* schema at `code`, and each body it admits parses.
21+
*/
22+
23+
import { describe, it, expect } from 'vitest';
24+
import type { z } from 'zod';
25+
import {
26+
ApiErrorSchema,
27+
BaseResponseSchema,
28+
makeApiErrorSchema,
29+
type ApiError,
30+
type ApiErrorParsed,
31+
type BaseResponse,
32+
} from './contract.zod';
33+
import { ErrorCode } from './error-code-ledger.zod';
34+
35+
type IsUnknown<T> = unknown extends T ? true : false;
36+
37+
// ── The input type of `code` is the vocabulary ────────────────────────────────────────────────
38+
39+
const codeIsTyped: [
40+
IsUnknown<z.input<typeof ErrorCode>>, IsUnknown<ApiError['code']>, IsUnknown<ApiErrorParsed['code']>,
41+
] = [false, false, false];
42+
const standardCode: ApiError = { code: 'VALIDATION_ERROR', message: 'x' };
43+
const registeredCode: ApiError['code'] = 'INVALID_ARTIFACT_PACKAGES';
44+
// @ts-expect-error -- `code` is a vocabulary member, not a number.
45+
const numericCode: ApiError = { code: 42, message: 'x' };
46+
// @ts-expect-error -- nor a string outside the vocabulary.
47+
const inventedCode: ApiError = { code: 'NOT_A_REGISTERED_CODE', message: 'x' };
48+
// @ts-expect-error -- the same `code` reaches every envelope built on `BaseResponseSchema`.
49+
const envelopeNumericCode: BaseResponse = { success: false, error: { code: 42, message: 'x' } };
50+
void [codeIsTyped, standardCode, registeredCode, numericCode, inventedCode, envelopeNumericCode];
51+
52+
// ── `makeApiErrorSchema`: standard catalogue ∪ the caller's codes ─────────────────────────────
53+
54+
const AcmeApiErrorSchema = makeApiErrorSchema(['ACME_QUOTA_EXCEEDED'] as const);
55+
type AcmeApiError = z.input<typeof AcmeApiErrorSchema>;
56+
const acmeCodeIsTyped: IsUnknown<AcmeApiError['code']> = false;
57+
const acmeSupplied: AcmeApiError = { code: 'ACME_QUOTA_EXCEEDED', message: 'x' };
58+
const acmeStandard: AcmeApiError = { code: 'RECORD_NOT_FOUND', message: 'x' };
59+
// @ts-expect-error -- a code neither standard nor supplied.
60+
const acmeUnsupplied: AcmeApiError = { code: 'ACME_NOT_SUPPLIED', message: 'x' };
61+
// @ts-expect-error -- nor a number.
62+
const acmeNumeric: AcmeApiError = { code: 42, message: 'x' };
63+
void [acmeCodeIsTyped, acmeSupplied, acmeStandard, acmeUnsupplied, acmeNumeric];
64+
65+
/** The one issue a refused body carries, reduced to what the envelope contract names. */
66+
function refusalAt(result: z.ZodSafeParseResult<unknown>): Array<{ code: string; path: PropertyKey[] }> {
67+
expect(result.success).toBe(false);
68+
return (result.error?.issues ?? []).map((issue) => ({ code: issue.code, path: issue.path }));
69+
}
70+
71+
describe('[#19920] ApiError.code is typed as the vocabulary its schema parses against', () => {
72+
it('every body the type refuses is refused by the schema at `code`', () => {
73+
expect(refusalAt(ApiErrorSchema.safeParse({ code: 42, message: 'x' }))).toEqual([
74+
{ code: 'invalid_value', path: ['code'] },
75+
]);
76+
expect(refusalAt(ApiErrorSchema.safeParse({ code: 'NOT_A_REGISTERED_CODE', message: 'x' }))).toEqual([
77+
{ code: 'invalid_value', path: ['code'] },
78+
]);
79+
expect(
80+
refusalAt(BaseResponseSchema.safeParse({ success: false, error: { code: 42, message: 'x' } })),
81+
).toEqual([{ code: 'invalid_value', path: ['error', 'code'] }]);
82+
expect(refusalAt(AcmeApiErrorSchema.safeParse({ code: 'ACME_NOT_SUPPLIED', message: 'x' }))).toEqual([
83+
{ code: 'invalid_value', path: ['code'] },
84+
]);
85+
});
86+
87+
it('every body the type admits parses', () => {
88+
expect(ApiErrorSchema.safeParse(standardCode).success).toBe(true);
89+
expect(ApiErrorSchema.safeParse({ code: registeredCode, message: 'x' }).success).toBe(true);
90+
expect(AcmeApiErrorSchema.safeParse(acmeSupplied).success).toBe(true);
91+
expect(AcmeApiErrorSchema.safeParse(acmeStandard).success).toBe(true);
92+
});
93+
});

‎packages/spec/src/api/contract.zod.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,9 @@ export function makeApiErrorSchema<const TExtra extends readonly string[]>(extra
284284
return ApiErrorSchema.extend({
285285
// The retired-spelling prescription rides this door too: `.options` above
286286
// carries the catalogue's members, not its error map (`retired-error-codes.ts`).
287-
code: (z.enum(vocabulary as [string, ...string[]], { error: retiredStandardErrorCodeMessage }) as z.ZodType<StandardErrorCode | TExtra[number]>)
287+
// [#19920] Both `z.ZodType` parameters are named, as on `ErrorCode`: naming
288+
// the output alone left this schema's INPUT `code` typed `unknown`.
289+
code: (z.enum(vocabulary as [string, ...string[]], { error: retiredStandardErrorCodeMessage }) as z.ZodType<StandardErrorCode | TExtra[number], StandardErrorCode | TExtra[number]>)
288290
.describe('Error code (StandardErrorCode ∪ the ledger this consumer registered)'),
289291
});
290292
}

‎packages/spec/src/api/error-code-ledger.zod.ts‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1390,14 +1390,29 @@ export const REGISTERED_ERROR_CODES: readonly RegisteredErrorCode[] = Object.fre
13901390
* standard catalog ∪ registered extension codes. This is what
13911391
* `ApiErrorSchema.code` parses against — an unregistered code is a schema
13921392
* failure, not a new dialect.
1393+
*
1394+
* [#19920] The cast names BOTH of `z.ZodType`'s parameters, `<Output, Input>`.
1395+
* The cast exists because the spread above erases the members to `string`.
1396+
* Naming only the output left `Input` at its default, `unknown`, so `ApiError`
1397+
* (the INPUT type of `ApiErrorSchema`) typed `code` as `unknown`:
1398+
* `{ code: 42, message: 'x' }` compiled as an `ApiError` while this schema
1399+
* refuses it. An enum's input is its output, so both parameters are the same
1400+
* union.
1401+
*
1402+
* Both are spelled with the {@link ErrorCode} type alias, never the union
1403+
* written out: declaration emit prints an inline union literal by literal at
1404+
* every schema that embeds this one (78 sites in the `api` entry), while an
1405+
* alias it prints by name. Measured over the built declarations: with the
1406+
* union inline in both parameters they grew by 3.36 MB (+11%); with the alias
1407+
* they shrink by 3.32 MB, since the output half was printed inline before too.
13931408
*/
13941409
export const ErrorCode = z.enum(
13951410
[...StandardErrorCode.options, ...REGISTERED_ERROR_CODES] as [string, ...string[]],
13961411
// `.options` carries the catalogue's members, not its error map — so a retired
13971412
// standard spelling would answer here with zod's bare enum message unless this
13981413
// door passes the same prescription (`retired-error-codes.ts`).
13991414
{ error: retiredStandardErrorCodeMessage },
1400-
) as z.ZodType<StandardErrorCode | RegisteredErrorCode>;
1415+
) as z.ZodType<ErrorCode, ErrorCode>;
14011416

14021417
export type ErrorCode = StandardErrorCode | RegisteredErrorCode;
14031418

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#19920] The flattened list overlay's legacy `options` bag carries each kind's own block, with
5+
* every key optional, not a string-keyed record of `unknown`.
6+
*
7+
* `listViewKindBlocks()` returned `Record<string, z.ZodTypeAny>`, so the bag's static type was
8+
* `{ [x: string]: unknown }` on the list overlay member, and so on `ViewMetadata`,
9+
* `ViewMetadataParsed`, `AssembledViewArtifact` and `AssembledViewArtifactParsed`:
10+
* `options: { foo: 1, kanban: 42 }` type-checked while that member refuses both keys. Its return
11+
* type is now derived by the same rule the loop runs (a value of the list shape's `type` enum that
12+
* also names a block on the shape), with zod's own `.partial()` type per block.
13+
*
14+
* Two halves, judged by two programs (the `view-overlay-viewkind-type.test.ts` shape):
15+
*
16+
* - The TYPE half is judged by `tsc -p tsconfig.test.json` (the package's `typecheck` script, via
17+
* `check:test-typecheck`), not by vitest. Each `@ts-expect-error` below asserts that its line
18+
* does NOT compile. While the bag was a record of `unknown` every one of them compiled, so each
19+
* directive was unused: TS2578 in a file with no `test-typecheck-debt.json` entry.
20+
* - The RUNTIME half ties it to the doors: the runtime key set is the type's key set, each body
21+
* the type refuses is refused by every door that judges it, and the partial underlay parses.
22+
*/
23+
24+
import { describe, it, expect } from 'vitest';
25+
import type { z } from 'zod';
26+
import {
27+
VIEW_METADATA_MEMBERS,
28+
ViewMetadataSchema,
29+
type ViewMetadata,
30+
type ViewMetadataParsed,
31+
} from './view.zod';
32+
import {
33+
AssembledViewArtifactSchema,
34+
type AssembledViewArtifact,
35+
type AssembledViewArtifactParsed,
36+
} from './assembled-views.zod';
37+
38+
type ListOverlayIn = z.input<typeof VIEW_METADATA_MEMBERS.listOverlay>;
39+
type ListOverlayOut = z.output<typeof VIEW_METADATA_MEMBERS.listOverlay>;
40+
type OptionsIn = NonNullable<ListOverlayIn['options']>;
41+
type OptionsOut = NonNullable<ListOverlayOut['options']>;
42+
43+
/** The kinds that name a block on the list shape: its `type` enum minus `grid`. */
44+
const KIND_BLOCKS = ['calendar', 'chart', 'gallery', 'gantt', 'kanban', 'map', 'timeline', 'tree'] as const;
45+
type KindBlock = (typeof KIND_BLOCKS)[number];
46+
47+
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
48+
type NeverEntries<T> = { [K in keyof T]-?: [NonNullable<T[K]>] extends [never] ? K : never }[keyof T];
49+
50+
// ── The bag's key set is the kind set, and no entry collapsed to `never` ─────────────────────
51+
52+
const keySetIsTheKindSet: [Equal<keyof OptionsIn, KindBlock>, Equal<keyof OptionsOut, KindBlock>] = [true, true];
53+
const noEntryIsNever: [[NeverEntries<OptionsIn>] extends [never] ? true : false] = [true];
54+
void [keySetIsTheKindSet, noEntryIsNever];
55+
56+
// ── Each entry is the kind's own block with every key optional ───────────────────────────────
57+
58+
// `groupByField` is required on the kanban block itself; the underlay may leave it out.
59+
const partialUnderlay: OptionsIn = { kanban: { summarizeField: 'amount' } };
60+
// @ts-expect-error -- `options.foo` is no kind.
61+
const unknownKind: OptionsIn = { foo: 1 };
62+
// @ts-expect-error -- `grid` names no block.
63+
const gridBlock: OptionsIn = { grid: {} };
64+
// @ts-expect-error -- a kind's entry is an object, not a number.
65+
const numericBlock: OptionsIn = { kanban: 42 };
66+
// @ts-expect-error -- and each key has the block's own type.
67+
const wrongKeyType: OptionsIn = { kanban: { groupByField: 42 } };
68+
// @ts-expect-error -- and the block is strict: a key it does not declare is refused.
69+
const undeclaredKey: OptionsIn = { kanban: { nope: 1 } };
70+
void [partialUnderlay, unknownKind, gridBlock, numericBlock, wrongKeyType, undeclaredKey];
71+
72+
// ── The body the member refuses no longer type-checks as any union type ──────────────────────
73+
74+
const typedBag: ViewMetadata = { object: 'crm_lead', viewKind: 'list', options: { kanban: { summarizeField: 'amount' } } };
75+
// @ts-expect-error -- a list overlay whose bag holds a number is no view body.
76+
const numericBagMetadata: ViewMetadata = { object: 'crm_lead', viewKind: 'list', options: { kanban: 42 } };
77+
// @ts-expect-error -- nor a parsed one.
78+
const numericBagParsedMetadata: ViewMetadataParsed = { object: 'crm_lead', viewKind: 'list', options: { kanban: 42 } };
79+
// @ts-expect-error -- nor a view artifact.
80+
const numericBagArtifact: AssembledViewArtifact = { object: 'crm_lead', viewKind: 'list', options: { kanban: 42 } };
81+
// @ts-expect-error -- nor a parsed one.
82+
const numericBagParsedArtifact: AssembledViewArtifactParsed = { object: 'crm_lead', viewKind: 'list', options: { kanban: 42 } };
83+
void [typedBag, numericBagMetadata, numericBagParsedMetadata, numericBagArtifact, numericBagParsedArtifact];
84+
85+
describe('[#19920] the list overlay options bag carries each kind block', () => {
86+
it('the runtime key set is the kind set the type names', () => {
87+
const bag = VIEW_METADATA_MEMBERS.listOverlay.shape.options.unwrap();
88+
expect(Object.keys(bag.shape).sort()).toEqual([...KIND_BLOCKS]);
89+
});
90+
91+
it('a bag the type refuses is refused by every door that judges it', () => {
92+
for (const options of [{ kanban: 42 }, { foo: 1 }, { kanban: { groupByField: 42 } }, { kanban: { nope: 1 } }]) {
93+
const body = { object: 'crm_lead', viewKind: 'list', options };
94+
for (const door of [VIEW_METADATA_MEMBERS.listOverlay, ViewMetadataSchema, AssembledViewArtifactSchema]) {
95+
expect(door.safeParse(body).success, JSON.stringify(options)).toBe(false);
96+
}
97+
}
98+
});
99+
100+
it('the partial underlay the type admits parses at every door', () => {
101+
const body = { object: 'crm_lead', viewKind: 'list', options: { kanban: { summarizeField: 'amount' } } };
102+
for (const door of [VIEW_METADATA_MEMBERS.listOverlay, ViewMetadataSchema, AssembledViewArtifactSchema]) {
103+
expect(door.safeParse(body).success).toBe(true);
104+
}
105+
});
106+
});

0 commit comments

Comments
 (0)