Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for readonly types #412

Closed
wants to merge 12 commits into from

Conversation

RubyTunaley
Copy link

This PR adds support for readonly types in the TypeScript output.

Details

Adds 3 new custom schema properties:

  • "tsReadonly", controlling whether an array or tuple type is readonly.
  • "tsReadonlyProperty", controlling whether a property in an object schema is readonly.
  • "tsReadonlyPropertyDefaultValue", which (when used on an object schema) sets the default value of "tsReadonlyProperty" properties on this object's properties (yes this sounds confusing, more on this later).

(I talk about why I'm not using the JSON Schema spec's "readOnly" property later).

Adds 2 new options:

  • readonlyByDefault, controlling the default values of "tsReadonly" and "tsReadonlyProperty". Defaults to false.
  • readonlyKeyword, controlling whether to write readonly array types as readonly X[] or ReadonlyArray<X>. Defaults to true. If set to false tuple types will always be mutable, regardless of other factors (more details later).

Property "tsReadonly"

  • Type: boolean

This property, when used on an array schema, controls whether said array schema should compile to a readonly TypeScript array/tuple type.

If unspecified, inherits its value from the readonlyByDefault option.

Examples

// readonlyByDefault: false
// readonlyKeyword: true
{
  "type": "array",
  "items": {
    "type": "string"
  },
  "tsReadonly": true
  // Outputs `readonly string[]`
}
// readonlyByDefault: false
// readonlyKeyword: true
{
  "type": "array",
  "minItems": 1,
  "items": {
    "type": "string"
  },
  "tsReadonly": true
  // Outputs `readonly [string, ...string[]]`
}
// readonlyByDefault: false
// readonlyKeyword: false
{
  "type": "array",
  "items": {
    "type": "string"
  },
  "tsReadonly": true
  // Outputs `ReadonlyArray<string>`
}
// readonlyByDefault: false
// readonlyKeyword: false
{
  "type": "array",
  "items": {
    "type": "string"
  },
  "tsReadonly": true
  // Outputs `ReadonlyArray<string>`
},
{
  "type": "array",
  "minItems": 1,
  "items": {
    "type": "string"
  },
  "tsReadonly": true
  // Outputs `[string, ...string[]]` (details later)
}

Property "tsReadonlyProperty"

  • Type: boolean

This property, when used on a property of an object schema, controls whether or not said property should be readonly in Typescript. Supports properties in "properties" and "patternProperties", as well as "additionalProperties".

If unspecified, inherits its value from:

  1. The value of the containing object schema's "tsReadonlyPropertyDefaultValue" property, if specified.
  2. The value of the readonlyByDefault option.

Examples

// readonlyByDefault: false
{
  "type": "object",
  "properties": {
    "foo": {
      "type": "string",
      "tsReadonlyProperty": true
    }
  },
  "additionalProperties": false
  // Outputs `{readonly foo?: string}`
}
// readonlyByDefault: false
{
  "type": "object",
  "additionalProperties": {
    "type": "string",
    "tsReadonlyProperty": true
  }
  // Outputs `{readonly [k: string]?: string}`
}
// readonlyByDefault: false
{
  "type": "object",
  "patternProperties": {
    "^[a-z]$": {
      "type": "string",
      "tsReadonlyProperty": true
    }
  },
  "additionalProperties": false
  // Outputs `{readonly [k: string]?: string}`
}

Why a Different Property?

We need different properties to talk about readonly array schemas vs. readonly properties because if we didn't the following case would be ambiguous:

// readonlyByDefault: false
{
  "type": "object",
  "additionalProperties": {
    "type": "array",
    "items": {
      "type": "string"
    },
    "tsReadonly": true
  }
}

Was the schema author's intent to mark the array schema as readonly, outputting {[k: string]: readonly string[]}, or was it to mark the property as readonly, outputting {readonly [k: string]: string[]}? Or maybe it was both? We can't tell with just one property, hence the need for two.

Property "tsReadonlyPropertyDefaultValue"

  • Type: boolean

This property, when used on an object schema, controls the default value for the "tsReadonlyProperty" properties on the object schema's properties. Essentially it's like applying Readonly to the entire object type, with the difference that properties can explicitly be marked as mutable.

Like Readonly, the value of this properly only applies to the immediate object schema. Sub-schemas, such as those defining the types of certain properties, are not affected by this property (unless said sub-schema is itself an object schema which specifies this property).

This property applies to immediate sub-schemas in "properties" and "patternProperties", as well as "additionalProperties".

This property technically has no default value, but based on code output it can be said to have an imaginary default value matching the value of the readonlyByDefault option (see the "tsReadonlyProperty" value inheritance chain).

Examples

// readonlyByDefault: false
{
  "type": "object",
  "tsReadonlyPropertyDefaultValue": true,
  "properties": {
    "foo": {
      "type": "string"
    }
  },
  "additionalProperties": true
  // Outputs `{readonly foo?: string, readonly [k: string]: string}`
}

Option readonlyByDefault

  • Type: boolean
  • Default Value: false

This option sets the default value for "tsReadonly" properties, and for "tsReadonlyProperty" properties that are not otherwise affected by "tsReadonlyPropertyDefaultValue". This can be thought of as the value of last resort for the first two properties.

This option defaults to false to preserve backwards compatibility.

Option readonlyKeyword

  • Type: boolean
  • Default Value: true

This option controls whether array schemas compile to readonly X[] or ReadonlyArray<X>. If true it compiles using the readonly keyword.

This option defaults to true because the TypeScript version that the readonly keyword was introduced in (3.4) is reasonably old, and setting it to false leaves us with no way to write a readonly tuple type (more on that in the next subsection).

The Tuple Type Problem

readonly as a type keyword was first introduced in TypeScript 3.4.

References:

The problem with this is that people with codebases stuck in earlier versions of TypeScript (for whatever arcane legacy code reason) don't have a way to represent readonly tuple types. As such, when readonlyKeyword is false, we have no choice but to fall back to outputting a mutable tuple type; readonly [string, ...string[]] becomes [string, ...string[]]. We could possibly throw an error instead, but I felt that this way was safer so that people who set readonlyByDefault to true at least get something as-is, instead of having to go through and explicitly specify "tsReadonly": false for every tuple type (or turn off tuple types altogether).

Notes on "readOnly"

The JSON Schema specification defines its own property - "readOnly" - to deal with at least some of what the custom properties in this PR do. Why not use it? Well...

  • It does not resolve the aforementioned conflict between readonly array schemas and readonly properties on object schemas (see "tsReadonlyProperty").
  • The spec is ambiguous about whether this properly applies to instances or properties.
  • The spec has a special case for when this property is present on the top-level schema.
  • Since "readOnly" is a standard JSON Schema property, using it to extract information about TypeScript readonly-ness is a breaking change and would require a major version increment.

I'm completely open to discussion regarding making use of "readOnly" in some way for TypeScript readonly detection, but I felt it safer to implement it like this for now.

Notes on Test Cases

I have written end-to-end tests to cover (almost all) combinations of property values, options, and inheritance chains.

That's a lot of test files. Like, 122.

If you feel that some of these tests are unnecessary or overkill feel absolutely free to delete them, or merge them all into one mega test file if that's something that's supported (I couldn't figure out how to do it). Or tell me to do it, I'm also fine with that.

Also feel free to rename them; the names are also stupidly long.

A Question Regarding "$ref"

"$ref" behaves a little bit strange because of the dereferencing step.

The following schema:

// readonlyByDefault: false
{
  "definitions": {
    "SomeType": {
      "type": "string",
      "tsReadonlyProperty": true
    }
  },
  "type": "object",
  "properties": {
    "foo": {
      "$ref": "#/definitions/SomeType"
    }
  },
  "additionalProperties": false
}

Compiles to:

export type SomeType = string;

export interface Test {
  readonly foo?: SomeType;
  additionalProperties?: false;
  [k: string]: unknown;
}

I could fix this by deleteing "tsReadonlyProperty" off of dereferenced refs, but I'm unsure if the current behaviour is actually desirable - it certainly would save some characters, and overriding it in the schema containing the "$ref" property does work. I'm leaving this as-is pending feedback, and as such there is currently no test case for this behaviour.

An Additional (Properties) Bugfix

As a side-effect of the main work done on this PR a bug with "additionalProperties" and custom properties was fixed.

Before

{
  "type": "object",
  "additionalProperties": {
    // Any custom property besides `"tsType"` works here
    "tsEnumNames": []
  }
  // Outputs {[k: string]: {[k: string]: unknown}}
}

After

{
  "type": "object",
  "additionalProperties": {
    "tsEnumNames": []
  }
  // Outputs {[k: string]: unknown}
}

This fix requires active maintenance if additional custom properties are defined - said custom property must be added as an exception in the nonCustomKeys function in src/typesOfSchema.ts.

A test case was added to enforce this bugfix.

@amh4r
Copy link

amh4r commented Sep 25, 2021

Was the schema author's intent to mark the array schema as readonly, outputting {[k: string]: readonly string[]}, or was it to mark the property as readonly, outputting {readonly [k: string]: string[]}? Or maybe it was both? We can't tell with just one property, hence the need for two.

What would be the downside of using JSON schema's readOnly and making both the TS property and array readonly?

interface Foo {
  readonly arr: readonly string[]
}

I agree that readonly support would be great. However, our team generates interfaces/classes in multiple languages from the same OpenAPI spec. We'd like to avoid having a bunch of language specific, non-official-OpenAPI attributes in our specs

Copy link
Owner

@bcherny bcherny left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey, thanks for putting up this PR and sorry for taking so long to give feedback on it. I appreciate the work and thoroughness you've put into it!

In general, I'm supportive of supporting readOnly. However, I'd suggest taking a slightly different approach:

  1. We should support the standard JSON-Schema readOnly keyword. We should not introduce our own JSTT-specific keywords, or add new options to JSTT. To address the questions in your summary: a breaking change + major version is the right approach here; the $ref behavior in this PR is correct; and the output I'd expect for your cases is:
// Input (1)
{
  "type": "object",
  "additionalProperties": {
    "type": "array",
    "items": {
      "type": "string"
    },
    "readOnly": true
  }
}

// Output (1)
interface Test {
  readonly [k: string]: string[];
}

// Input (2)
{
  "type": "object",
  "additionalProperties": {
    "readOnly": true,
    "type": "array",
    "items": {
      "type": "string"
    }
  }
}

// Output (2)
interface Test {
 [k: string]: readonly string[];
}
  1. For your tests, you did add a lot and once again I appreciate the attention to detail you put into it. However, I'd suggest combining tests a bit more to avoid testing unnecessary cases. Off the top of my head, the key cases you want to test are (am I missing anything?):
    1. readOnly properties: objects, arrays, tuples, primitives, etc.
    2. readOnly definitions
    3. readOnly x allOf
    4. readOnly x allOf, where each branch of the allOf has the same property, but just one of the branches is marked readOnly (correct behavior per the spec: the result should be readOnly)
    5. readOnly x anyOf

See https://github.com/bcherny/json-schema-to-typescript/pull/353/files, if you'd like to combine its approach with the one in this PR.

/**
* Makes Windows paths look like POSIX paths, ignoring drive letter prefixes (if present).
*/
export function forcePosixLikePath(path: string): string {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull this into a separate PR, please.

@bcherny
Copy link
Owner

bcherny commented May 4, 2023

Closing out stale PRs. Feel free to re-open if you'd like to revisit this.

@bcherny bcherny closed this May 4, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Add option for generate interface with readonly properties
3 participants