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
20 changes: 20 additions & 0 deletions .changeset/19920-exported-types-family-close.md
Original file line number Diff line number Diff line change
@@ -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.

<!-- 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. -->
93 changes: 93 additions & 0 deletions packages/spec/src/api/api-error-code-type.test.ts
Original file line number Diff line number Diff line change
@@ -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<StandardErrorCode | RegisteredErrorCode>`. `z.ZodType` takes
* two type parameters, `<Output, Input>`, 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<T> = unknown extends T ? true : false;

// ── The input type of `code` is the vocabulary ────────────────────────────────────────────────

const codeIsTyped: [
IsUnknown<z.input<typeof ErrorCode>>, IsUnknown<ApiError['code']>, IsUnknown<ApiErrorParsed['code']>,
] = [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<typeof AcmeApiErrorSchema>;
const acmeCodeIsTyped: IsUnknown<AcmeApiError['code']> = 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<unknown>): 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);
});
});
4 changes: 3 additions & 1 deletion packages/spec/src/api/contract.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,9 @@ export function makeApiErrorSchema<const TExtra extends readonly string[]>(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<StandardErrorCode | TExtra[number]>)
// [#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<StandardErrorCode | TExtra[number], StandardErrorCode | TExtra[number]>)
.describe('Error code (StandardErrorCode ∪ the ledger this consumer registered)'),
});
}
Expand Down
17 changes: 16 additions & 1 deletion packages/spec/src/api/error-code-ledger.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1390,14 +1390,29 @@ 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, `<Output, Input>`.
* 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[]],
// `.options` carries the catalogue's members, not its error map — so a retired
// 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<StandardErrorCode | RegisteredErrorCode>;
) as z.ZodType<ErrorCode, ErrorCode>;

export type ErrorCode = StandardErrorCode | RegisteredErrorCode;

Expand Down
106 changes: 106 additions & 0 deletions packages/spec/src/ui/view-overlay-options-type.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, z.ZodTypeAny>`, 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<typeof VIEW_METADATA_MEMBERS.listOverlay>;
type ListOverlayOut = z.output<typeof VIEW_METADATA_MEMBERS.listOverlay>;
type OptionsIn = NonNullable<ListOverlayIn['options']>;
type OptionsOut = NonNullable<ListOverlayOut['options']>;

/** 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<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
type NeverEntries<T> = { [K in keyof T]-?: [NonNullable<T[K]>] extends [never] ? K : never }[keyof T];

// ── The bag's key set is the kind set, and no entry collapsed to `never` ─────────────────────

const keySetIsTheKindSet: [Equal<keyof OptionsIn, KindBlock>, Equal<keyof OptionsOut, KindBlock>] = [true, true];
const noEntryIsNever: [[NeverEntries<OptionsIn>] 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);
}
});
});
Loading
Loading