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
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ See [server demo](example) and [browser demo](https://github.com/bcherny/json-sc
| unknownAny | boolean | `true` | Use `unknown` instead of `any` where possible |
| unreachableDefinitions | boolean | `false` | Generates code for `definitions` that aren't referenced by the schema. |
| strictIndexSignatures | boolean | `false` | Append all index signatures with `\| undefined` so that they are strictly typed. |
| readonlyByDefault | boolean | `false` | This is the implied value for unspecified `"tsReadonly"` and `"tsReadonlyProperty"` properties. |
| readonlyKeyword | boolean | `true` | Use the `readonly` keyword instead of `ReadonlyArray` for `array` types. **WARNING:** Setting this to `false` will disable readonly tuple support. |
| $refOptions | object | `{}` | [$RefParser](https://github.com/BigstickCarpet/json-schema-ref-parser) Options, used when resolving `$ref`s |
## CLI

Expand Down Expand Up @@ -164,11 +166,112 @@ json2ts -i foo.json -o foo.d.ts --style.singleQuote --no-style.semi
- [x] literal objects in enum ([eg](https://github.com/tdegrunt/jsonschema/blob/67c0e27ce9542efde0bf43dc1b2a95dd87df43c3/examples/all.js#L236))
- [x] referencing schema by id ([eg](https://github.com/tdegrunt/jsonschema/blob/67c0e27ce9542efde0bf43dc1b2a95dd87df43c3/examples/all.js#L331))
- [x] custom typescript types via `tsType`
- [x] support for `readonly` types via `tsReadonly`
- [x] support for `readonly` properties via `tsReadonlyProperty`

## Custom schema properties:

- `tsType`: Overrides the type that's generated from the schema. Useful for forcing a type to `any` or when using non-standard JSON schema extensions ([eg](https://github.com/sokra/json-schema-to-typescript/blob/f1f40307cf5efa328522bb1c9ae0b0d9e5f367aa/test/e2e/customType.ts)).
- `tsEnumNames`: Overrides the names used for the elements in an enum. Can also be used to create string enums ([eg](https://github.com/johnbillion/wp-json-schemas/blob/647440573e4a675f15880c95fcca513fdf7a2077/schemas/properties/post-status-name.json)).
- `tsReadonly`: Sets whether an array is `readonly` in TypeScript.

```jsonc
// readonlyByDefault: false
{
"anyOf": [
{
"type": "array",
"items": {
"type": "string"
},
"tsReadonly": true
// Compiles to `readonly string[]`
},
{
"type": "array",
"minItems": 1,
"items": {
"type": "string"
},
"tsReadonly": true
// Compiles to `readonly [string, ...string[]]`
}
]
}
```

If unspecified, defaults to the value of the *readonlyByDefault* option.

- `tsReadonlyProperty`: Sets whether an object property is `readonly` in TypeScript.

```jsonc
// readonlyByDefault: false
{
"type": "object",
"properties": {
"foo": {
"type": "string",
"tsReadonlyProperty": true
}
},
"additionalProperties": {
"type": "number",
"tsReadonlyProperty": true
}
// Compiles to `{ readonly foo: string, readonly [k: string]: number }`
}
```

If unspecified, defaults to:

1. The value of the containing object schema's `tsReadonlyPropertyDefaultValue` property, if it is specified.
2. The value of the *readonlyByDefault* option.

<!-- For clearance -->
<p></p>

- `tsReadonlyPropertyDefaultValue`: Sets the default value of `tsReadonlyProperty` for an object schema's properties. This is the same as passing the object type to `Readonly`, except that properties can be explicitly marked as non-readonly.

```jsonc
{
"type": "object",
"tsReadonlyPropertyDefaultValue": true,
"properties": {
// This property is readonly
"foo": {},
// This property is NOT readonly because we explicitly override the object's default
"bar": {
"tsReadonlyProperty": false
}
},
// Additional properties are readonly
"additionalProperties": true
}
```

Note that this property has no effect on sub-objects.

```jsonc
// readonlyByDefault: false
{
"type": "object",
"tsReadonlyPropertyDefaultValue": true,
"properties": {
// This property is readonly
"foo": {
"type": "object",
"properties": {
// This property is NOT readonly
"bar": {
"type": "string"
}
},
// Additional properties are NOT readonly
"additionalProperties": true
}
}
}
```

## Not expressible in TypeScript:

Expand Down
37 changes: 30 additions & 7 deletions src/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ import {
TIntersection,
TNamedInterface,
TUnion,
T_UNKNOWN
T_UNKNOWN,
TTuple
} from './types/AST'
import {log, toSafeString} from './utils'

Expand Down Expand Up @@ -178,8 +179,16 @@ function generateRawType(ast: AST, options: Options): string {
return 'any'
case 'ARRAY':
return (() => {
const type = generateType(ast.params, options)
return type.endsWith('"') ? '(' + type + ')[]' : type + '[]'
let type = generateType(ast.params, options)
if (ast.isReadonly && !options.readonlyKeyword) {
type = 'ReadonlyArray<' + type + '>'
} else {
type = type.endsWith('"') ? '(' + type + ')[]' : type + '[]'
if (ast.isReadonly) {
type = 'readonly ' + type
}
}
return type
})()
case 'BOOLEAN':
return 'boolean'
Expand Down Expand Up @@ -230,7 +239,14 @@ function generateRawType(ast: AST, options: Options): string {
}

function paramsToString(params: string[]): string {
return '[' + params.join(', ') + ']'
let type = '[' + params.join(', ') + ']'
// `Readonly<T>` where T is a tuple type is unsupported in versions of TypeScript prior to the introduction
// of the `readonly` keyword for array/tuple types, so don't bother adding it if options.readonlyKeyword is
// false
// Sources:
// https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-4.html#a-new-syntax-for-readonlyarray
// https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-4.html#readonly-tuples
return (ast as TTuple).isReadonly && options.readonlyKeyword ? 'readonly ' + type : type
}

const paramsList = astParams.map(param => generateType(param, options))
Expand Down Expand Up @@ -302,12 +318,19 @@ function generateInterface(ast: TInterface, options: Options): string {
ast.params
.filter(_ => !_.isPatternProperty && !_.isUnreachableDefinition)
.map(
({isRequired, keyName, ast}) =>
[isRequired, keyName, ast, generateType(ast, options)] as [boolean, string, AST, string]
({isRequired, isReadonlyParam, keyName, ast}) =>
[isRequired, isReadonlyParam, keyName, ast, generateType(ast, options)] as [
boolean,
boolean,
string,
AST,
string
]
)
.map(
([isRequired, keyName, ast, type]) =>
([isRequired, isReadonlyParam, keyName, ast, type]) =>
(hasComment(ast) && !ast.standaloneName ? generateComment(ast.comment) + '\n' : '') +
(isReadonlyParam ? 'readonly ' : '') +
escapeKeyName(keyName) +
(isRequired ? '' : '?') +
': ' +
Expand Down
12 changes: 12 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,16 @@ export interface Options {
* Ignore maxItems and minItems for `array` types, preventing tuples being generated.
*/
ignoreMinAndMaxItems: boolean
/**
* This is the implied value for unspecified `"tsReadonly"` and `"tsReadonlyProperty"` properties.
*/
readonlyByDefault: boolean
/**
* Use the `readonly` keyword instead of `ReadonlyArray<T>` for array types.
*
* **WARNING:** Setting this to `false` will disable readonly tuple support.
*/
readonlyKeyword: boolean
/**
* Append all index signatures with `| undefined` so that they are strictly typed.
*
Expand Down Expand Up @@ -79,6 +89,8 @@ export const DEFAULT_OPTIONS: Options = {
enableConstEnums: true,
format: true,
ignoreMinAndMaxItems: false,
readonlyByDefault: false,
readonlyKeyword: true,
strictIndexSignatures: false,
style: {
bracketSpacing: false,
Expand Down
16 changes: 16 additions & 0 deletions src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ function parseNonLiteral(
minItems,
standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames),
params: schema.items.map(_ => parse(_, options, undefined, processed, usedNames)),
isReadonly: schema.tsReadonly ?? options.readonlyByDefault,
type: 'TUPLE'
}
if (schema.additionalItems === true) {
Expand All @@ -238,6 +239,7 @@ function parseNonLiteral(
keyName,
standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames),
params: parse(schema.items!, options, undefined, processed, usedNames),
isReadonly: schema.tsReadonly ?? options.readonlyByDefault,
type: 'ARRAY'
}
}
Expand Down Expand Up @@ -278,6 +280,7 @@ function parseNonLiteral(
// if there is no maximum, then add a spread item to collect the rest
spreadParam: maxItems >= 0 ? undefined : params,
standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames),
isReadonly: schema.tsReadonly ?? options.readonlyByDefault,
type: 'TUPLE'
}
}
Expand All @@ -287,6 +290,7 @@ function parseNonLiteral(
keyName,
params,
standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames),
isReadonly: schema.tsReadonly ?? options.readonlyByDefault,
type: 'ARRAY'
}
}
Expand Down Expand Up @@ -355,6 +359,8 @@ function parseSchema(
isPatternProperty: false,
isRequired: includes(schema.required || [], key),
isUnreachableDefinition: false,
// Readonly state specified on property supercedes readonly state specified on the object
isReadonlyParam: value.tsReadonlyProperty ?? schema.tsReadonlyPropertyDefaultValue ?? options.readonlyByDefault,
keyName: key
}))

Expand All @@ -376,6 +382,8 @@ via the \`patternProperty\` "${key}".`
isPatternProperty: !singlePatternProperty,
isRequired: singlePatternProperty || includes(schema.required || [], key),
isUnreachableDefinition: false,
isReadonlyParam:
value.tsReadonlyProperty ?? schema.tsReadonlyPropertyDefaultValue ?? options.readonlyByDefault,
keyName: singlePatternProperty ? '[k: string]' : key
}
})
Expand All @@ -394,6 +402,8 @@ via the \`definition\` "${key}".`
isPatternProperty: false,
isRequired: includes(schema.required || [], key),
isUnreachableDefinition: true,
isReadonlyParam:
value.tsReadonlyProperty ?? schema.tsReadonlyPropertyDefaultValue ?? options.readonlyByDefault,
keyName: key
}
})
Expand All @@ -412,6 +422,7 @@ via the \`definition\` "${key}".`
isPatternProperty: false,
isRequired: true,
isUnreachableDefinition: false,
isReadonlyParam: schema.tsReadonlyPropertyDefaultValue ?? options.readonlyByDefault,
keyName: '[k: string]'
})

Expand All @@ -426,6 +437,11 @@ via the \`definition\` "${key}".`
isPatternProperty: false,
isRequired: true,
isUnreachableDefinition: false,
// Explicit additionalProperties readonly state supercedes generic readonly state
isReadonlyParam:
schema.additionalProperties.tsReadonlyProperty ??
schema.tsReadonlyPropertyDefaultValue ??
options.readonlyByDefault,
keyName: '[k: string]'
})
}
Expand Down
3 changes: 3 additions & 0 deletions src/types/AST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export interface TAny extends AbstractAST {
export interface TArray extends AbstractAST {
type: 'ARRAY'
params: AST
isReadonly: boolean
}

export interface TBoolean extends AbstractAST {
Expand Down Expand Up @@ -85,6 +86,7 @@ export interface TInterfaceParam {
isRequired: boolean
isPatternProperty: boolean
isUnreachableDefinition: boolean
isReadonlyParam: boolean
}

export interface TIntersection extends AbstractAST {
Expand Down Expand Up @@ -124,6 +126,7 @@ export interface TTuple extends AbstractAST {
spreadParam?: AST
minItems: number
maxItems?: number
isReadonly: boolean
}

export interface TUnion extends AbstractAST {
Expand Down
20 changes: 20 additions & 0 deletions src/types/JSONSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,26 @@ export interface JSONSchema extends JSONSchema4 {
* schema extension to support custom types
*/
tsType?: string
/**
* schema extension to support readonly types
*/
tsReadonly?: boolean
/**
* schema extension to support readonly properties
*/
tsReadonlyProperty?: boolean
/**
* schema extension to support changing the default value of tsReadonlyProperty on this schema's properties
*/
tsReadonlyPropertyDefaultValue?: boolean

// NOTE: When adding a new custom property, you MUST ALSO add that custom property as an exclusion in the
// nonCustomKeys function in src/typesOfSchema.ts
// If you do not do this weird things happen with otherwise empty schemas:
// {"title": "X", "additionalProperties": {"myCustomProperty": null}}
// Outputs: interface X {[k: string]: {[k: string]: unknown}}
// Instead of the expected: interface X {[k: string]: unknown}
// (or [k: string]: any depending on options)
}

export const Parent = Symbol('Parent')
Expand Down
13 changes: 12 additions & 1 deletion src/typesOfSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,23 @@ export function typesOfSchema(schema: JSONSchema): readonly [SchemaType, ...Sche
return matchedTypes as [SchemaType, ...SchemaType[]]
}

function nonCustonKeys(obj: JSONSchema): string[] {
return Object.keys(obj).filter(
key =>
key !== 'tsEnumNames' &&
key !== 'tsType' &&
key !== 'tsReadonly' &&
key !== 'tsReadonlyProperty' &&
key !== 'tsReadonlyPropertyDefaultValue'
)
}

const matchers: Record<SchemaType, (schema: JSONSchema) => boolean> = {
ALL_OF(schema) {
return 'allOf' in schema
},
ANY(schema) {
if (Object.keys(schema).length === 0) {
if (nonCustonKeys(schema).length === 0) {
// The empty schema {} validates any value
// @see https://json-schema.org/draft-07/json-schema-core.html#rfc.section.4.3.1
return true
Expand Down
9 changes: 8 additions & 1 deletion src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,13 @@ export function escapeBlockComment(schema: JSONSchema) {
}
}

/**
* 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.

return path.replace(/\\/gu, '/')
}

/*
the following logic determines the out path by comparing the in path to the users specified out path.
For example, if input directory MultiSchema looks like:
Expand All @@ -291,7 +298,7 @@ export function pathTransform(outputPath: string, inputPath: string, filePath: s
const filePathList = dirname(normalize(filePath)).split(sep)
const filePathRel = filePathList.filter((f, i) => f !== inPathList[i])

return join(normalize(outputPath), ...filePathRel)
return forcePosixLikePath(join(normalize(outputPath), ...filePathRel))
}

/**
Expand Down
Loading