Skip to content

Commit e84bbf6

Browse files
os-salesclaude
andauthored
fix(spec): reject unknown keys on nested action-param literals at compile time (#12860)
ObjectSchema.create() infers its generic from the argument, so tsc's excess-property (freshness) checking never fires at any depth; the existing NoExcessObjectKeys map compensated only at the top level. A typo'd key on an actions[].params[] literal passed tsc clean and was caught only by ActionParamSchema's strict parse at module load. Mirror the same Record<excess-key, never> map over each element of each action's params array so the typo becomes a located tsc error at the authoring site. Compile-layer signal only: the strict parse at module load stays the enforcement of record; zero changes to any Zod schema's accept/reject or to parse timing. Claude-Session: https://claude.ai/code/session_01JvjTCjJQn9zSTXEhUKgT7s Co-authored-by: Claude <noreply@anthropic.com>
1 parent 277948f commit e84bbf6

3 files changed

Lines changed: 132 additions & 2 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
fix(spec): `ObjectSchema.create()` rejects unknown keys on nested action-param literals at compile time (#12615)
6+
7+
`create()` infers its generic from the argument, so TypeScript's
8+
excess-property (freshness) checking never fires at any depth; the existing
9+
`NoExcessObjectKeys` map compensated only at the top level. A typo'd key on an
10+
`actions[].params[]` literal (measured: `carryOverX` on
11+
`sys-permission-set.object.ts`) therefore passed `tsc` clean and was caught
12+
only by `ActionParamSchema`'s strict parse at module load.
13+
14+
The same `Record<excess-key, never>` map is now mirrored over each element of
15+
each action's `params` array, so the typo becomes a located `tsc` error at the
16+
authoring site (`error TS2322 … 'true' is not assignable to 'never'` pointing
17+
at the unknown key).
18+
19+
Compile-layer signal only — shipped as `patch` because no working code
20+
changes meaning: the strict parse at module load stays the enforcement of
21+
record, nothing changes in what parses or when, and every literal the new
22+
constraint refuses was already refused (later, at import) by that parse.

‎packages/spec/src/data/object.test.ts‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1350,6 +1350,80 @@ describe('ObjectSchema.create()', () => {
13501350
expect(obj.validations).toEqual([]);
13511351
});
13521352
});
1353+
1354+
// [#12615] The #1535 compile-layer rejection, one level DOWN. `create()`
1355+
// infers `T` from its argument, so tsc's excess-property (freshness) check
1356+
// never fires at ANY depth; the top-level `Record<…, never>` map compensated
1357+
// only for top-level keys, and a typo'd key on a nested action-param literal
1358+
// (measured 2026-08-26: `carryOverX` on `sys-permission-set.object.ts`)
1359+
// passed the package typecheck CLEAN — caught only by `ActionParamSchema`'s
1360+
// strict parse at module load. `NoExcessNestedActionParams` mirrors the map
1361+
// over `actions[].params[]`; these pins hold both layers:
1362+
// - the `@ts-expect-error` line is the COMPILE pin — if the nested map is
1363+
// removed, the directive goes unused and `check:test-typecheck` fails;
1364+
// - the `.toThrow` is the RUNTIME pin — the strict parse stays the
1365+
// enforcement of record, unchanged, for non-literal (dynamic) configs.
1366+
describe('excess-key rejection on nested action-param literals (#12615)', () => {
1367+
it('a typo\'d param key is refused at compile time AND still refused by the load-time strict parse', () => {
1368+
expect(() => ObjectSchema.create({
1369+
name: 'demo',
1370+
fields: { status: { type: 'text' } },
1371+
actions: [
1372+
{
1373+
name: 'clone_demo',
1374+
label: 'Clone',
1375+
mode: 'custom',
1376+
locations: ['list_item'],
1377+
type: 'api',
1378+
method: 'POST',
1379+
target: '/api/v1/data/demo',
1380+
params: [
1381+
{
1382+
field: 'status',
1383+
defaultFromRow: true,
1384+
carryOver: true,
1385+
// @ts-expect-error — `carryOverX` is not an ActionParam key (the #12615 compile pin)
1386+
carryOverX: true,
1387+
},
1388+
],
1389+
},
1390+
],
1391+
})).toThrow(/carryOverX/);
1392+
});
1393+
1394+
it('positive control: every canonical param spelling still compiles and parses untouched', () => {
1395+
const obj = ObjectSchema.create({
1396+
name: 'demo',
1397+
fields: { status: { type: 'text' } },
1398+
actions: [
1399+
{
1400+
// An action with NO params — the nested map must leave it alone.
1401+
name: 'activate_demo',
1402+
label: 'Activate',
1403+
mode: 'custom',
1404+
locations: ['list_item'],
1405+
type: 'api',
1406+
method: 'PATCH',
1407+
target: '/api/v1/data/demo/{id}',
1408+
},
1409+
{
1410+
name: 'clone_demo',
1411+
label: 'Clone',
1412+
mode: 'custom',
1413+
locations: ['list_item'],
1414+
type: 'api',
1415+
method: 'POST',
1416+
target: '/api/v1/data/demo',
1417+
params: [
1418+
{ name: 'label', label: 'New Name', type: 'text', required: true, helpText: 'display name' },
1419+
{ field: 'status', defaultFromRow: true, carryOver: true },
1420+
],
1421+
},
1422+
],
1423+
});
1424+
expect(obj.actions).toHaveLength(2);
1425+
});
1426+
});
13531427
});
13541428

13551429
// ============================================================================

‎packages/spec/src/data/object.zod.ts‎

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { z } from 'zod';
44
import { FieldSchema } from './field.zod';
55
import { ValidationRuleSchema } from './validation.zod';
6-
import { ActionSchema } from '../ui/action.zod';
6+
import { ActionSchema, type ActionParam } from '../ui/action.zod';
77
import { ObjectListViewSchema } from '../ui/view.zod';
88

99
/**
@@ -2358,7 +2358,41 @@ function unknownKeyError(objectName: unknown, unknownKeys: string[], knownKeys:
23582358
* silent strip into a `tsc` error at the authoring site as well as at build.
23592359
*/
23602360
type NoExcessObjectKeys<T> = T &
2361-
Record<Exclude<keyof T, keyof z.input<typeof ObjectSchemaBase>>, never>;
2361+
Record<Exclude<keyof T, keyof z.input<typeof ObjectSchemaBase>>, never> &
2362+
NoExcessNestedActionParams<T>;
2363+
2364+
/**
2365+
* [#12615] Extends the compile-time excess-key rejection one level DOWN, to the
2366+
* action-param literals nested inside `actions[].params[]`.
2367+
*
2368+
* Why the top-level trick alone does not reach them: `create()` infers `T`
2369+
* from the argument itself, so the argument always matches `T` exactly and
2370+
* TypeScript's excess-property (freshness) checking never fires — at any
2371+
* depth. {@link NoExcessObjectKeys} compensates at the top level by mapping
2372+
* every non-schema key to `never`; nested literals had no such map, so a
2373+
* typo'd param key (measured 2026-08-26: `carryOverX` on
2374+
* `sys-permission-set.object.ts`) sailed through `tsc` and was caught only by
2375+
* `ActionParamSchema`'s strict parse at module load. This mirrors the same
2376+
* map over each element of each action's `params` array, turning the typo
2377+
* into a located compile error at the authoring site.
2378+
*
2379+
* Compile-layer signal only — the strict parse at module load remains the
2380+
* enforcement of record; nothing here changes what parses or when.
2381+
*/
2382+
type NoExcessNestedActionParams<T> =
2383+
T extends { actions: infer As extends readonly unknown[] }
2384+
? { actions: { [I in keyof As]: NoExcessActionParams<As[I]> } }
2385+
: unknown;
2386+
2387+
/** Maps one action literal: constrain each of its `params` entries, if any. */
2388+
type NoExcessActionParams<A> =
2389+
A extends { params: infer Ps extends readonly unknown[] }
2390+
? A & { params: { [I in keyof Ps]: NoExcessActionParamKeys<Ps[I]> } }
2391+
: A;
2392+
2393+
/** One action-param literal: every key outside `ActionParam` becomes `never`. */
2394+
type NoExcessActionParamKeys<P> = P &
2395+
Record<Exclude<keyof P, keyof ActionParam>, never>;
23622396

23632397
/** Object names already warned about a generic `password` field (dedup per name). */
23642398
const warnedPasswordObjects = new Set<string>();

0 commit comments

Comments
 (0)