# 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:

``` yaml
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:

``` yaml
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:

``` ts
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:

``` yaml
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:

``` yaml
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:

``` yaml
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:

``` yaml
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](https://www.powerduck.com/app/?ref=powerduck.com). To see how these map types flow into a generated SDK, read [generating a TypeScript client from OpenAPI](https://www.powerduck.com/blog/generate-typescript-client-from-openapi/).

