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

ShapeSchemaTypeScriptJava
Fixed record, closedproperties + additionalProperties: falseA named interfaceA POJO
Dictionary / mapadditionalProperties: {schema}Record<string, T>Map<String, T>
Free-formadditionalProperties: 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: 0

Known 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: string

patternProperties 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 as true, they emit an index signature or Object.
  • Runtime validators differ on whether an omitted additionalProperties allows 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 of any in generated contracts.

Checklist

  1. Classify each object as a closed record, a typed map, or genuinely free-form before writing the schema.
  2. Closed request records set additionalProperties: false; public extension surfaces stay open on purpose.
  3. Typed maps put the value schema under additionalProperties, with a realistic example showing two or three keys.
  4. Keep structural fields typed and confine the opaque bag to a dedicated metadata property.
  5. On 3.1, use propertyNames / patternProperties for key rules when your toolchain supports them; otherwise document the rule.
  6. Use free-form only for truly opaque passthrough data, and say so in the description.
  7. Generate the client and confirm maps become Record/Map types 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.