OpenAPI additionalProperties: modeling maps, dictionaries, labels, and free-form objects without losing types
The word "object" hides three completely different data shapes. A User is a fixed record with known fields. A map of language codes to translations is an object whose keys are arbitrary but whose values all share a type. A webhook payload you store and forward is genuinely free-form. Model all three as type: object with nothing else, and a generator either types the whole thing as Record<string, any> or as an empty interface that rejects real data.
additionalProperties is the keyword that tells these shapes apart.
The three object shapes
| Shape | Schema | TypeScript | Java |
|---|---|---|---|
| Fixed record, closed | properties + additionalProperties: false | A named interface | A POJO |
| Dictionary / map | additionalProperties: {schema} | Record<string, T> | Map<String, T> |
| Free-form | additionalProperties: true (or {}) | Record<string, unknown> | Map<String, Object> |
A fixed record should usually be closed
When every field is known, set additionalProperties: false so a typo is a validation error instead of silently accepted junk:
CreateAddress:
type: object
additionalProperties: false
required: [country, city, line1]
properties:
line1: { type: string }
line2: { type: string }
city: { type: string }
postal_code: { type: string }
country:
type: string
pattern: '^[A-Z]{2}$'This drives strict request validation: sending cit instead of city returns a 400 pointing at the unknown property instead of creating an address missing the city. Note the trade-off: a closed record rejects forward-compatible clients that send a new field before you documented it. For internal request DTOs that is usually what you want; for public webhook or extension surfaces it is not.
A dictionary uses additionalProperties as the value type
Translations, feature flags, per-region prices, and label maps all have unknown keys but uniform values. The value schema goes under additionalProperties; there is no values keyword:
ProductLocalization:
type: object
description: Map of BCP 47 language tag to localized copy.
additionalProperties:
type: object
additionalProperties: false
required: [title]
properties:
title: { type: string, maxLength: 200 }
description: { type: string }
example:
en: { title: "Ceramic knife" }
fr: { title: "Couteau en céramique" }
ja: { title: "セラミックナイフ" }The outer object is a map keyed by language tag; each value is itself a closed record. Nesting the two shapes is how you keep types all the way down. A generator produces:
export type ProductLocalization = Record<
string,
{ title: string; description?: string }
>;A numeric map is the same pattern, for example a quota object whose keys are plan codes and whose values are integer limits:
QuotaMap:
type: object
additionalProperties:
type: integer
minimum: 0Known fields plus an open map
You often have a few fixed fields and an open-ended bag of metadata on the same object. Declare the known fields as properties and give additionalProperties the type of the dynamic bag:
Event:
type: object
required: [id, type]
properties:
id: { type: string, format: uuid }
type: { type: string }
occurred_at: { type: string, format: date-time }
metadata:
type: object
additionalProperties: true
description: Customer-defined key/value pairs echoed back verbatim.Here metadata is explicitly free-form while the envelope stays typed. That is better than making the whole event free-form: the structural fields still validate and generate types, and only the customer-owned bag is opaque.
Constrain the keys when you can
In OpenAPI 3.1 (full JSON Schema), propertyNames and patternProperties let you constrain or partition a map by key. Use them for maps whose keys follow a rule:
Headers:
type: object
propertyNames:
pattern: '^[A-Za-z0-9-]+$'
additionalProperties:
type: stringpatternProperties is the right tool when different key prefixes have different value types, such as a configuration object where max_* keys are integers and allow_* keys are booleans. Tooling support is newer than additionalProperties, so check that your generator and validator honor these keywords before relying on them; when in doubt, document the key rule in description and keep a single additionalProperties value type.
Free-form should be honest, not lazy
Reach for a genuinely free-form object only when the value is genuinely opaque to you: a passthrough webhook body, a user-defined JSON preference, a raw extension document. Model it explicitly and say why:
RawExtension:
type: object
additionalProperties: true
description: >-
Opaque provider-defined JSON. Stored and returned unmodified; the schema is
not validated because third parties add fields without notice.Typing this as Record<string, unknown> is correct. What you must not do is leave a normal business object as an unconstrained object out of convenience. That silently downgrades every field to any, which defeats codegen, disables validation, and lets an AI caller invent fields that do not exist. If you know the shape, say the shape.
What codegen, validators, and mocks do
- Generators key off
additionalProperties: absent it, many emit a closed named type; present with a schema, they emit a map type; present astrue, they emit an index signature orObject. - Runtime validators differ on whether an omitted
additionalPropertiesallows unknown keys. JSON Schema defaults to allowing them; some OpenAPI validators default to stripping them. State your intent explicitly instead of relying on the default. - A spec-driven mock generates a dictionary with two or three realistic keys from the
example, and an empty object for a closed record's optional map. A free-form object gets a minimal placeholder rather than fabricated structure, which is the honest behavior. - When reverse-engineering a spec from code, a sound scanner reads the generic type argument (
Map<String, Localization>,Record<string, number>) to recover the value schema. A scanner that only looks at field names types every map as free-form, which is one of the most common sources ofanyin generated contracts.
Checklist
- Classify each object as a closed record, a typed map, or genuinely free-form before writing the schema.
- Closed request records set
additionalProperties: false; public extension surfaces stay open on purpose. - Typed maps put the value schema under
additionalProperties, with a realisticexampleshowing two or three keys. - Keep structural fields typed and confine the opaque bag to a dedicated
metadataproperty. - On 3.1, use
propertyNames/patternPropertiesfor key rules when your toolchain supports them; otherwise document the rule. - Use free-form only for truly opaque passthrough data, and say so in the description.
- Generate the client and confirm maps become
Record/Maptypes and closed records reject unknown properties.
Get these right and your generated types stop collapsing into any at the exact boundary where APIs carry the most customer-specific data.
You can model closed records, typed dictionaries, and free-form metadata, generate map types, and validate all three against a mock in one local-first workspace, right in your browser. To see how these map types flow into a generated SDK, read generating a TypeScript client from OpenAPI.