@@ -105,13 +105,40 @@ export type ProjectableRefinement =
105105 readonly pattern : 'dependent-required' ;
106106 /** Key ⇒ the keys its presence requires. Read once here, and by the predicate. */
107107 readonly dependencies : Readonly < Record < string , readonly string [ ] > > ;
108- } ;
108+ }
109+ /**
110+ * "no document may carry any of these keys" — published as `propertyNames`
111+ * with a `not` over the banned names, the spelling JSON Schema has for a rule
112+ * about NAMES rather than about values.
113+ *
114+ * Exact in the JSON domain: a JSON object's properties are exactly its own
115+ * enumerable string-keyed ones, and `propertyNames` judges exactly those
116+ * names, so "none of the banned names is an own property" and "no property
117+ * name is one of the banned names" are one sentence read from two ends. It is
118+ * PRESENCE and never value — a banned key present with a `null` value is
119+ * present on both sides, the same equality {@link requiredOneOf} rests on.
120+ *
121+ * ⛔ The predicate reads OWN properties and never `key in value`. `in` walks
122+ * the prototype chain, so a ban on a name `Object.prototype` carries —
123+ * `toString`, `constructor`, `valueOf` — would refuse every object including
124+ * `{}`, while `propertyNames` accepts it: `'toString' in JSON.parse('{}')` is
125+ * `true`. That is a disagreement about a JSON DOCUMENT, not an edge outside
126+ * the domain, and an arm that could only approximate its rule does not belong
127+ * in this list.
128+ *
129+ * ⛔ A ban over an open set of names — every key starting with `$`, say — is
130+ * NOT this arm: its keys are a finite list, and a list that merely sampled an
131+ * open set would be wider than the rule. Such a rule stays dropped and
132+ * annotated until the list learns a pattern-shaped arm of its own.
133+ */
134+ | { readonly pattern : 'banned-keys' ; readonly keys : readonly string [ ] } ;
109135
110136/** Every arm's `pattern` tag, for a reader that needs the list itself. */
111137export const PROJECTABLE_REFINEMENT_PATTERNS = [
112138 'required-one-of' ,
113139 'non-blank-string' ,
114140 'dependent-required' ,
141+ 'banned-keys' ,
115142] as const ;
116143
117144/**
@@ -224,3 +251,33 @@ export function dependentRequired<K extends string, D extends string>(
224251 } ;
225252 return declare ( rule , declared ) ;
226253}
254+
255+ /**
256+ * "none of these keys is present", as a `.refine()` predicate that also
257+ * declares itself.
258+ *
259+ * The key list is read once into the declaration and the predicate reads it
260+ * from there, so the published `propertyNames` and the enforced rule cannot
261+ * name different keys — the same construction {@link requiredOneOf} and
262+ * {@link dependentRequired} use, and the reason this arm needs no drift pin
263+ * either.
264+ *
265+ * Spell the slot's own banned keys at the call site:
266+ *
267+ * ```ts
268+ * z.record(z.string(), z.unknown()).refine(bannedKeys(['dialect']), {
269+ * message: 'A structured filter must not carry `dialect`',
270+ * abort: true,
271+ * })
272+ * ```
273+ */
274+ export function bannedKeys < K extends string > (
275+ keys : readonly [ K , ...K [ ] ] ,
276+ ) : ( value : object ) => boolean {
277+ const declared : ProjectableRefinement = { pattern : 'banned-keys' , keys : Object . freeze ( [ ...keys ] ) } ;
278+ const rule = ( value : object ) : boolean =>
279+ ! ( declared as { keys : readonly string [ ] } ) . keys . some ( ( key ) =>
280+ Object . prototype . hasOwnProperty . call ( value , key ) ,
281+ ) ;
282+ return declare ( rule , declared ) ;
283+ }
0 commit comments