Skip to content

Commit 777b509

Browse files
committed
fix(spec): a flattened list overlay's type and columns carry the list shape's types
listOverlayPatchFields() read `type` and `columns` off ListViewShapeSchema through a cast of its shape to a record of z.ZodTypeAny, which typed both keys `unknown` on the list overlay member. So `{ object, viewKind: 'list', columns: 42 }` type-checked as ViewMetadata, ViewMetadataParsed, AssembledViewArtifact and AssembledViewArtifactParsed while that member refuses it. The shape is now read as typed; the schemas are the same objects, so no parse moves. The item-4 TSDoc sentences on ViewMetadataParsed and AssembledViewArtifactParsed no longer say the two keys are `unknown`, and the pin that held them `unknown` is replaced by pins that they are typed, with four refused-body probes and a runtime half. The changeset names the fourth narrowing. Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude <noreply@anthropic.com>
1 parent 406335a commit 777b509

4 files changed

Lines changed: 61 additions & 23 deletions

File tree

‎.changeset/19920-exported-types-remainder.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,20 @@
22
'@objectstack/spec': minor
33
---
44

5-
fix(spec): `JoinedReportBlock`, a ViewItem's `config` and a flattened overlay's `viewKind` carry the shapes their doors accept (#19920)
5+
fix(spec): `JoinedReportBlock`, a ViewItem's `config`, a flattened overlay's `viewKind` and a flattened list overlay's `type` / `columns` carry the shapes their doors accept (#19920)
66

77
Clause-②: yes (narrowing)
88

99
**BREAKING for TypeScript code that annotates with `JoinedReportBlock`, `Report`, `ReportParsed`, `ViewItem`, `ViewItemWire`, `ViewMetadata`, `ViewMetadataParsed`, `AssembledViewArtifact` or `AssembledViewArtifactParsed`, or that passes an unchecked value to `defineReport` / `defineViewItem`**: 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 existing export changes. Three parsed-state type names are added (below); nothing is removed or renamed.
1010

11-
Three places in the published types were wider than the doors that judge the same bodies, so values those doors refuse type-checked:
11+
Four places in the published types were wider than the doors that judge the same bodies, so values those doors refuse type-checked:
1212

1313
- `JoinedReportBlock`: FROM `unknown` TO the input shape of `JoinedReportBlockSchema`. The schema was annotated `z.ZodTypeAny`, which erased its shape; it now carries its inferred type. The same erasure made every `blocks[]` element of `Report` / `ReportParsed` (and so of `defineReport`'s parameter) `unknown`; each is now a block.
1414
- A ViewItem's `config`: FROM `unknown` TO the arm's own config type, a `ListView` config on the `list` arm and a `FormView` config on the `form` arm. This holds on `ViewItem`, `ViewItemWire`, `defineViewItem`'s parameter and return, and the `viewItem` member of `ViewMetadata`, `ViewMetadataParsed`, `AssembledViewArtifact` and `AssembledViewArtifactParsed`. The arm builder took `config` as `z.ZodTypeAny`; it is now a generic parameter.
1515
- A flattened overlay member's `viewKind`: FROM `'list' | 'form'` on both members TO `'list'` on the list overlay and `'form'` on the form overlay, the one value each member accepts. A list-shaped body naming `viewKind: 'form'` used to type-check, through the list overlay member, as `ViewMetadata`, `ViewMetadataParsed`, `AssembledViewArtifact` and `AssembledViewArtifactParsed`.
16+
- A flattened list overlay's `type` and `columns`: FROM `unknown` TO the list view's own types, both optional: `type` one of the list view types, `columns` a field list. This holds on the list overlay member of `ViewMetadata`, `ViewMetadataParsed`, `AssembledViewArtifact` and `AssembledViewArtifactParsed`. The member read both keys off the list view shape through a cast that erased them, so `{ object, viewKind: 'list', columns: 42 }` type-checked as all four while that member refuses it.
1617

17-
**If your code stops compiling.** A value you annotated with one of these names, or passed to `defineReport` / `defineViewItem`, 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. A ViewItem's `config` must match its `viewKind`: a `ListView` config under `viewKind: 'list'`, a `FormView` config under `viewKind: 'form'`.
18+
**If your code stops compiling.** A value you annotated with one of these names, or passed to `defineReport` / `defineViewItem`, 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. A ViewItem's `config` must match its `viewKind`: a `ListView` config under `viewKind: 'list'`, a `FormView` config under `viewKind: 'form'`. A flattened list overlay's `columns` is a field list and its `type` one of the list view types.
1819

1920
The declared types of `JoinedReportBlockSchema`, `ViewItemSchema` and `ViewItemWireSchema` narrow with them, so `z.input` / `z.infer` of each is typed where it was `unknown` (or carried an `unknown` `config`). Typed, each schema's input and output now differ by its defaults, so three ADR-0122 parsed-state aliases are added beside the bare names: `JoinedReportBlockParsed`, `ViewItemParsed` and `ViewItemWireParsed`. Nothing is removed or renamed.
2021

‎packages/spec/src/ui/assembled-views.zod.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,9 +122,9 @@ export type AssembledViewArtifact = z.input<(typeof VIEW_METADATA_MEMBERS)[Exclu
122122
* [#19920] One default is applied by the parse but absent from this type, the one
123123
* `ViewMetadataParsed` (`view.zod.ts`) names: the flattened list overlay member declares `type`
124124
* without the list shape's `.default('grid')` and re-applies it in an `.overwrite()`, which
125-
* returns the member's own output type. So on that member `type` stays optional here (and, like
126-
* its `columns`, typed `unknown`), while every body it parses comes back with `type` set:
127-
* `'grid'` when the body named none.
125+
* returns the member's own output type. So on that member `type` stays optional here (typed as
126+
* the list shape's `type` enum), while every body it parses comes back with `type` set: `'grid'`
127+
* when the body named none.
128128
*/
129129
export type AssembledViewArtifactParsed = z.infer<(typeof VIEW_METADATA_MEMBERS)[Exclude<ViewMetadataBranch, 'container'>]>;
130130

‎packages/spec/src/ui/view-overlay-viewkind-type.test.ts‎

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22

33
/**
44
* [#19920] Each flattened overlay member's static `viewKind` is its own arm's literal: `'list'` on
5-
* the list overlay, `'form'` on the form overlay. And the list overlay's `type` default, which the
6-
* parse applies and the output type does not carry, is what the `ViewMetadataParsed` /
5+
* the list overlay, `'form'` on the form overlay. The list overlay's `type` and `columns` carry the
6+
* list shape's own types, not `unknown`. And the list overlay's `type` default, which the parse
7+
* applies and the output type does not carry, is what the `ViewMetadataParsed` /
78
* `AssembledViewArtifactParsed` TSDoc says it is.
89
*
910
* `flattenedViewOverlayFields(kind)` took `kind: 'list' | 'form'`, so `z.enum([kind])` widened to
@@ -12,6 +13,11 @@
1213
* `AssembledViewArtifactParsed`, while both doors refuse it (the list member: the arm mismatch; the
1314
* form member: `type` and `columns`). The function is now generic, so each member keeps its literal.
1415
*
16+
* `listOverlayPatchFields()` read the list overlay's `type` and `columns` off the list shape
17+
* through a cast to a record of `z.ZodTypeAny`, which typed both `unknown` on that member:
18+
* `{ object, viewKind: 'list', columns: 42 }` type-checked as the same four union types while the
19+
* list member refuses it. It now reads the shape as typed.
20+
*
1521
* Two halves, judged by two programs (the `view-metadata-type.test.ts` shape):
1622
*
1723
* - The TYPE half is judged by `tsc -p tsconfig.test.json` (the package's `typecheck` script, via
@@ -70,17 +76,38 @@ const parsedMetadata: ViewMetadataParsed = { type: 'grid', columns: ['name'], ob
7076
void [listKindIn, listKindOut, formKindIn, formKindOut, listKindInForm, listKindOutForm, formKindInList, formKindOutList];
7177
void [artifact, parsedArtifact, metadata, parsedMetadata];
7278

79+
// ── The list overlay's `type` and `columns` carry the list shape's types ─────────────────────
80+
81+
type IsUnknown<T> = unknown extends T ? true : false;
82+
const typeAndColumnsAreTypedThere: [
83+
IsUnknown<ListOverlayIn['type']>, IsUnknown<ListOverlayIn['columns']>,
84+
IsUnknown<ListOverlayOut['type']>, IsUnknown<ListOverlayOut['columns']>,
85+
] = [false, false, false, false];
86+
const listOverlayPatchWithType: ListOverlayIn = { object: 'crm_lead', viewKind: 'list', type: 'kanban', columns: ['name'] };
87+
// @ts-expect-error -- the list overlay's `columns` is a field list, not a number.
88+
const listOverlayColumnsNumber: ListOverlayIn['columns'] = 42;
89+
// @ts-expect-error -- its `type` is the list shape's enum.
90+
const listOverlayTypeUnknown: ListOverlayIn['type'] = 'spreadsheet';
91+
// @ts-expect-error -- a list overlay whose `columns` is a number is no view artifact.
92+
const numericColumnsArtifact: AssembledViewArtifact = { object: 'crm_lead', viewKind: 'list', columns: 42 };
93+
// @ts-expect-error -- nor a parsed one.
94+
const numericColumnsParsedArtifact: AssembledViewArtifactParsed = { object: 'crm_lead', viewKind: 'list', columns: 42 };
95+
// @ts-expect-error -- nor a view body.
96+
const numericColumnsMetadata: ViewMetadata = { object: 'crm_lead', viewKind: 'list', columns: 42 };
97+
// @ts-expect-error -- nor a parsed one.
98+
const numericColumnsParsedMetadata: ViewMetadataParsed = { object: 'crm_lead', viewKind: 'list', columns: 42 };
99+
void [typeAndColumnsAreTypedThere, listOverlayPatchWithType, listOverlayColumnsNumber, listOverlayTypeUnknown];
100+
void [numericColumnsArtifact, numericColumnsParsedArtifact, numericColumnsMetadata, numericColumnsParsedMetadata];
101+
73102
// ── The list overlay's `type` default: applied by the parse, absent from the output type ─────
74103
//
75-
// The TSDoc on `ViewMetadataParsed` / `AssembledViewArtifactParsed` says both things in words, and
76-
// these two lines compile only while each one holds. The day a change carries the `.overwrite()`
77-
// default into the output type, or types the member's `type` / `columns`, a line stops compiling
78-
// and the TSDoc sentences are then false: correct them with it.
104+
// The TSDoc on `ViewMetadataParsed` / `AssembledViewArtifactParsed` says this in words, and this
105+
// line compiles only while it holds. The day a change carries the `.overwrite()` default into the
106+
// output type, the line stops compiling and the TSDoc sentences are then false: correct them with
107+
// it.
79108
type IsOptionalKey<T, K extends keyof T> = {} extends Pick<T, K> ? true : false;
80-
type IsUnknown<T> = unknown extends T ? true : false;
81109
const typeIsOptionalOnListOverlayOutput: IsOptionalKey<ListOverlayOut, 'type'> = true;
82-
const typeAndColumnsAreUnknownThere: [IsUnknown<ListOverlayOut['type']>, IsUnknown<ListOverlayOut['columns']>] = [true, true];
83-
void [typeIsOptionalOnListOverlayOutput, typeAndColumnsAreUnknownThere];
110+
void [typeIsOptionalOnListOverlayOutput];
84111

85112
describe('[#19920] the flattened overlay members keep their own viewKind literal', () => {
86113
it('the list-shaped `viewKind: "form"` body is refused by every door that judges it', () => {
@@ -91,6 +118,13 @@ describe('[#19920] the flattened overlay members keep their own viewKind literal
91118
expect(AssembledViewArtifactSchema.safeParse(body).success).toBe(false);
92119
});
93120

121+
it('a list overlay whose `columns` is a number is refused by every door that judges it', () => {
122+
const body = { object: 'crm_lead', viewKind: 'list', columns: 42 };
123+
expect(VIEW_METADATA_MEMBERS.listOverlay.safeParse(body).success).toBe(false);
124+
expect(ViewMetadataSchema.safeParse(body).success).toBe(false);
125+
expect(AssembledViewArtifactSchema.safeParse(body).success).toBe(false);
126+
});
127+
94128
it("a column-less list patch parses with `type: 'grid'`, which its output type leaves optional", () => {
95129
const patch = { object: 'crm_lead', viewKind: 'list', sort: [{ field: 'name', order: 'asc' }] };
96130
for (const door of [VIEW_METADATA_MEMBERS.listOverlay, ViewMetadataSchema, AssembledViewArtifactSchema]) {

‎packages/spec/src/ui/view.zod.ts‎

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5779,13 +5779,17 @@ const FORM_OVERLAY_COLUMNS_IS_A_COUNT =
57795779
* {@link assertViewIdentity} records for the union's own door.
57805780
*/
57815781
function listOverlayPatchFields() {
5782-
const shape = (ListViewShapeSchema as unknown as { shape: Record<string, z.ZodTypeAny> }).shape;
5783-
const type = shape.type as unknown as z.ZodDefault<z.ZodTypeAny>;
5782+
// [#19920] Read off the shape as typed, not through a cast to a record of
5783+
// `z.ZodTypeAny`: that cast erased both keys to `unknown` on this member, so
5784+
// `{ object, viewKind: 'list', columns: 42 }` type-checked as every union
5785+
// type read off {@link VIEW_METADATA_MEMBERS} while this member refuses it.
5786+
// The schemas are the same objects either way; only their static types move.
5787+
const shape = ListViewShapeSchema.shape;
57845788
return {
5785-
columns: shape.columns!.optional(),
5789+
columns: shape.columns.optional(),
57865790
// `.meta({ default })` keeps the served JSON Schema's `default: 'grid'`
57875791
// byte-identical: the default is real, applied by the overwrite below.
5788-
type: type.unwrap().optional().meta({ default: LIST_OVERLAY_DEFAULT_TYPE }),
5792+
type: shape.type.unwrap().optional().meta({ default: LIST_OVERLAY_DEFAULT_TYPE }),
57895793
};
57905794
}
57915795

@@ -6791,10 +6795,9 @@ export type ViewMetadata = z.input<(typeof VIEW_METADATA_MEMBERS)[ViewMetadataBr
67916795
* overlay member (`VIEW_METADATA_MEMBERS.listOverlay`) declares `type` without the list shape's
67926796
* `.default('grid')`, so its checks can tell a column-less patch from a full config, and
67936797
* re-applies the default in `.overwrite(applyListOverlayTypeDefault)`. An `.overwrite()` returns
6794-
* the member's own output type, so on that member `type` stays optional here, while every body
6795-
* that member parses comes back with `type` set: `'grid'` when the body named none. On that
6796-
* member `type` and `columns` are also typed `unknown`: `listOverlayPatchFields` reads both off
6797-
* the list shape through an untyped cast.
6798+
* the member's own output type, so on that member `type` stays optional here (typed as the list
6799+
* shape's `type` enum), while every body that member parses comes back with `type` set: `'grid'`
6800+
* when the body named none.
67986801
*/
67996802
export type ViewMetadataParsed = z.infer<(typeof VIEW_METADATA_MEMBERS)[ViewMetadataBranch]>;
68006803
export type ViewScope = z.input<typeof ViewScopeSchema>;

0 commit comments

Comments
 (0)