json-schema-to-typescript icon indicating copy to clipboard operation
json-schema-to-typescript copied to clipboard

Broken generation with additionalProperties

Open isaacl opened this issue 4 years ago • 10 comments

> tsc --strictNullChecks Broken.d.ts
Broken.d.ts:11:3 - error TS2411: Property 'message' of type 'string | undefined' is not assignable to string index type 'string'.

Generated via v10.0.0:

...
export interface FormObject {
  message?: string;
  [k: string]: string;
}

Schema

{
  "allOf": [{ "$ref": "#/definitions/FormObject" }],
  "$schema": "http://json-schema.org/draft-07/schema#",
  "definitions": {
    "FormObject": {
      "properties": {
        "message": {
          "type": "string",
          "maxLength": 4000
        }
      },
      "additionalProperties": {
        "type": "string",
        "maxLength": 100
      },
      "type": "object"
    }
  }
}

I left the maxLength properties in so you can see why I wrote the schema like this.

isaacl avatar Dec 17 '20 12:12 isaacl

From ts handbook

While string index signatures are a powerful way to describe the “dictionary” pattern, they also enforce that all properties match their return type. This is because a string index declares that obj.property is also available as obj["property"].

isaacl avatar Dec 17 '20 12:12 isaacl

So this is more broken than I thought. Basically the index type in typescript enforces on all existing properties, while in json additionalProperties spec only applies to things not listed in properties.

isaacl avatar Dec 17 '20 12:12 isaacl

additionalProperties probably needs to always translate to

[k: string]: unknown;

isaacl avatar Dec 17 '20 12:12 isaacl

This is a bug, and we should find a better way to handle this case.

One option is to always emit index signatures as [k: string]: T | undefined. In your case, [k: string]: string | undefined would fix the TS error.

However, I'm not sure this is always desirable, and if it wasn't for the message property, this may impose an unreasonable burden on consumers, who then need to check every index property for undefined (note: if a consumer is using the --noUncheckedIndexedAccess TSC flag this may be what they want, but it's a burned for those consumers that don't use the flag).

A better approach may be to change the way we emit index signatures, to always emit them as an intersection type:

export type FormObject = {[k: string]: string} & {message?: string}

declare const x: FormObject

x.a.toUpperCase() // OK
x.message.toUpperCase() // Error: Object is possibly 'undefined'

I'd love input from others. Are there cases this might not handle? Examples welcome.

bcherny avatar Dec 19 '20 21:12 bcherny

Unfortunately it's also broken for required properties that simply have a different type. This is invalid because the index type must contain the union of all listed types. So really I think the only option is to put the index type as 'unknown'.

...
export interface FormObject {
  message: number;
  [k: string]: string;
}

generated by:

{
  "allOf": [{ "$ref": "#/definitions/FormObject" }],
  "$schema": "http://json-schema.org/draft-07/schema#",
  "definitions": {
    "FormObject": {
      "properties": {
        "message": {
          "type": "number"
        }
      },
      "required": ["message"],
      "additionalProperties": {
        "type": "string"
      },
      "type": "object"
    }
  }
}

isaacl avatar Dec 19 '20 21:12 isaacl

Yeah intersection might work

isaacl avatar Dec 19 '20 21:12 isaacl

This is a bug, and we should find a better way to handle this case.

The error in the body of the original issue is a duplicate of #235 which was resolved by implementing --strictIndexSignatures.

(which I agree should ideally be on by default).

However, I'm not sure this is always desirable, and if it wasn't for the message property, this may impose an unreasonable burden on consumers, who then need to check every index property for undefined

This should be desirable as IMO it gives the more accurate type - while noUncheckedIndexedAccess now does effectively add this, it applies to more than just this case (i.e array index access).

If a set of properties are known on the object, then there are a few ways of handling that - most powerfully you can use declaration merging, which is how we handle this over at DefinitelyTyped i.e for process.env:

declare global {
  export namespace NodeJS {
    export interface ProcessEnv {
      // eslint-disable-next-line @typescript-eslint/naming-convention
      GITHUB_WEBHOOK_SECRET: string;
    }
  }
}

This doesn't require the global or namespace, so using your example:

export type Demo = FormObject;

export interface FormObject {
  [k: string]: string | undefined;
}

export interface FormObject {
  message: string;
}

const fn = (demo: Demo) => {
  console.log(demo.message.toUpperCase()); // fine

  if(demo.body) {
    console.log(demo.body.toUpperCase()); // fine
  }

  console.log(demo.header.toUpperCase()); // error!
}

playground link.

emit them as an intersection type: Are there cases this might not handle?

Yes, it prevents declaration merging as that can only be done with interfaces. This is why in DefinitelyTyped we tend to use empty interfaces (or interfaces with an index) as it lets you do cool things in apps like defining known headers for webhook lambdas:

declare module 'aws-lambda' {
  export interface APIGatewayProxyEventHeaders {
    'X-GitHub-Event': GithubEventName;
    'X-GitHub-Delivery': string;
    'X-Hub-Signature': string;
    'X-Hub-Signature-256': string;
  }
}

G-Rath avatar Dec 25 '20 01:12 G-Rath

Facing the same issue for the patternProperties JSON schema keyword where definitions does not match.

{
  "allOf": [{ "$ref": "#/definitions/FormObject" }],
  "$schema": "http://json-schema.org/draft-07/schema#",
  "definitions": {
    "FormObject": {
      "properties": {
        "message": {
          "type": "number"
        }
      },
      "required": ["message"],
      "patternProperties": {
        "^[a-zA-Z0-9]{1,255}$": {
          "type": "string"
        }
      },
      "type": "object"
    }
  }
}

is generating

export interface FormObject {
  message: number;
  /**
   * This interface was referenced by `undefined`'s JSON-Schema definition
   * via the `patternProperty` "^[a-zA-Z0-9]{1,255}$".
   */
  [k: string]: string;
}

However, using union types rather than intersection seems to do the trick. Am I missing something or would you consider treating additionalProperties/patternProperties as a union like shown below ?

export type FormObject =
  | { message: number; }
  /**
   * This interface was referenced by `undefined`'s JSON-Schema definition
   * via the `patternProperty` "^[a-zA-Z0-9]{1,255}$".
   */
  | { [k: string]: string; }

fredericbarthelet avatar Feb 16 '21 23:02 fredericbarthelet

However, using union types rather than intersection seems to do the trick. Am I missing something or would you consider treating additionalProperties/patternProperties as a union like shown below ?

export type FormObject =
  | { message: number; }
  /**
   * This interface was referenced by `undefined`'s JSON-Schema definition
   * via the `patternProperty` "^[a-zA-Z0-9]{1,255}$".
   */
  | { [k: string]: string; }

That type is not representative for the JSON schema though, no? (I'm new to JSON schema so I might be interpreting it wrong) As I understand the JSON Schema defines the object to have a property message of type number, AND additional properties with keys that match the pattern and that have values of type string. This union type in your code defines an object that has a property message of type number OR has properties with a value of type string. An intersect type would correctly define the type as having both a message property of type number AND additional properties with type string.

jeroenpelgrims avatar Mar 26 '21 23:03 jeroenpelgrims

Hi! Any chance of reviewing PR #383 which might close this issue?

fabrykowski avatar Apr 06 '22 10:04 fabrykowski