json-schema-to-typescript
Version:
compile json schema to typescript typings
645 lines (590 loc) • 21.9 kB
text/typescript
import {omit} from 'lodash'
import {memoize} from './memoize'
import {DEFAULT_OPTIONS, Options} from './index'
import {
AST,
ASTWithStandaloneName,
hasComment,
hasStandaloneName,
omitStandaloneName,
T_ANY,
T_ANY_ADDITIONAL_PROPERTIES,
TArray,
TEnum,
TInterface,
TInterfaceParam,
TIntersection,
TNamedInterface,
TUnion,
T_UNKNOWN,
T_UNKNOWN_ADDITIONAL_PROPERTIES,
} from './types/AST'
import {log, toSafeString} from './utils'
export function generate(ast: AST, options = DEFAULT_OPTIONS): string {
return (
[
options.bannerComment,
declareNamedTypes(ast, options, ast.standaloneName!),
declareNamedInterfaces(ast, options, ast.standaloneName!),
declareEnums(ast, options),
]
.filter(Boolean)
.join('\n\n') + '\n'
) // trailing newline
}
function declareEnums(ast: AST, options: Options, processed = new Set<AST>()): string {
if (processed.has(ast)) {
return ''
}
processed.add(ast)
let type = ''
switch (ast.type) {
case 'ENUM':
return generateStandaloneEnum(ast, options) + '\n'
case 'ARRAY':
return declareEnums(ast.params, options, processed)
case 'UNION':
case 'INTERSECTION':
return ast.params.reduce((prev, ast) => prev + declareEnums(ast, options, processed), '')
case 'TUPLE':
type = ast.params.reduce((prev, ast) => prev + declareEnums(ast, options, processed), '')
if (ast.spreadParam) {
type += declareEnums(ast.spreadParam, options, processed)
}
return type
case 'INTERFACE':
return getSuperTypesAndParams(ast).reduce((prev, ast) => prev + declareEnums(ast, options, processed), '')
default:
return ''
}
}
function declareNamedInterfaces(ast: AST, options: Options, rootASTName: string, processed = new Set<AST>()): string {
if (processed.has(ast)) {
return ''
}
processed.add(ast)
let type = ''
switch (ast.type) {
case 'ARRAY':
type = declareNamedInterfaces((ast as TArray).params, options, rootASTName, processed)
break
case 'INTERFACE':
type = [
hasStandaloneName(ast) &&
(ast.standaloneName === rootASTName || options.declareExternallyReferenced || ast.isUnreachableDefinition) &&
generateStandaloneInterface(ast, options),
getSuperTypesAndParams(ast)
.map(ast => declareNamedInterfaces(ast, options, rootASTName, processed))
.filter(Boolean)
.join('\n'),
]
.filter(Boolean)
.join('\n')
break
case 'INTERSECTION':
case 'TUPLE':
case 'UNION':
type = ast.params
.map(_ => declareNamedInterfaces(_, options, rootASTName, processed))
.filter(Boolean)
.join('\n')
if (ast.type === 'TUPLE' && ast.spreadParam) {
type += declareNamedInterfaces(ast.spreadParam, options, rootASTName, processed)
}
break
default:
type = ''
}
return type
}
function declareNamedTypes(ast: AST, options: Options, rootASTName: string, processed = new Set<AST>()): string {
if (processed.has(ast)) {
return ''
}
processed.add(ast)
switch (ast.type) {
case 'ARRAY':
return [
declareNamedTypes(ast.params, options, rootASTName, processed),
hasStandaloneName(ast) ? generateStandaloneType(ast, options) : undefined,
]
.filter(Boolean)
.join('\n')
case 'ENUM':
return ''
case 'INTERFACE':
return getSuperTypesAndParams(ast)
.map(
ast =>
(ast.standaloneName === rootASTName ||
options.declareExternallyReferenced ||
ast.isUnreachableDefinition) &&
declareNamedTypes(ast, options, rootASTName, processed),
)
.filter(Boolean)
.join('\n')
case 'INTERSECTION':
case 'TUPLE':
case 'UNION':
return [
hasStandaloneName(ast) ? generateStandaloneType(ast, options) : undefined,
ast.params
.map(ast => declareNamedTypes(ast, options, rootASTName, processed))
.filter(Boolean)
.join('\n'),
'spreadParam' in ast && ast.spreadParam
? declareNamedTypes(ast.spreadParam, options, rootASTName, processed)
: undefined,
]
.filter(Boolean)
.join('\n')
default:
if (hasStandaloneName(ast)) {
return generateStandaloneType(ast, options)
}
return ''
}
}
export const generateType = memoize(generateRawType)
function generateRawType(ast: AST, options: Options): string {
log('magenta', 'generator', ast)
if (hasStandaloneName(ast)) {
return toSafeString(ast.standaloneName)
}
switch (ast.type) {
case 'ANY':
return 'any'
case 'ARRAY':
return (() => {
const type = generateType(ast.params, options)
return type.endsWith('"') ? '(' + type + ')[]' : type + '[]'
})()
case 'BOOLEAN':
return 'boolean'
case 'INTERFACE':
return generateInterface(ast, options)
case 'INTERSECTION':
return generateSetOperation(ast, options)
case 'LITERAL':
return JSON.stringify(ast.params)
case 'NEVER':
return 'never'
case 'NUMBER':
return 'number'
case 'NULL':
return 'null'
case 'OBJECT':
return 'object'
case 'REFERENCE':
return ast.params
case 'STRING':
return 'string'
case 'TUPLE':
return (() => {
const minItems = ast.minItems
const maxItems = ast.maxItems || -1
let spreadParam = ast.spreadParam
const astParams = [...ast.params]
if (minItems > 0 && minItems > astParams.length && ast.spreadParam === undefined) {
// this is a valid state, and JSONSchema doesn't care about the item type
if (maxItems < 0) {
// no max items and no spread param, so just spread any
spreadParam = options.unknownAny ? T_UNKNOWN : T_ANY
}
}
if (maxItems > astParams.length && ast.spreadParam === undefined) {
// this is a valid state, and JSONSchema doesn't care about the item type
// fill the tuple with any elements
for (let i = astParams.length; i < maxItems; i += 1) {
astParams.push(options.unknownAny ? T_UNKNOWN : T_ANY)
}
}
function addSpreadParam(params: string[]): string[] {
if (spreadParam) {
const spread = '...(' + generateType(spreadParam, options) + ')[]'
params.push(spread)
}
return params
}
function paramsToString(params: string[]): string {
return '[' + params.join(', ') + ']'
}
const paramsList = astParams.map(param => generateType(param, options))
if (paramsList.length > minItems) {
/*
if there are more items than the min, we return a union of tuples instead of
using the optional element operator. This is done because it is more typesafe.
// optional element operator
type A = [string, string?, string?]
const a: A = ['a', undefined, 'c'] // no error
// union of tuples
type B = [string] | [string, string] | [string, string, string]
const b: B = ['a', undefined, 'c'] // TS error
*/
const cumulativeParamsList: string[] = paramsList.slice(0, minItems)
const typesToUnion: string[] = []
if (cumulativeParamsList.length > 0) {
// actually has minItems, so add the initial state
typesToUnion.push(paramsToString(cumulativeParamsList))
} else {
// no minItems means it's acceptable to have an empty tuple type
typesToUnion.push(paramsToString([]))
}
for (let i = minItems; i < paramsList.length; i += 1) {
cumulativeParamsList.push(paramsList[i])
if (i === paramsList.length - 1) {
// only the last item in the union should have the spread parameter
addSpreadParam(cumulativeParamsList)
}
typesToUnion.push(paramsToString(cumulativeParamsList))
}
return typesToUnion.join('|')
}
// no max items so only need to return one type
return paramsToString(addSpreadParam(paramsList))
})()
case 'UNION':
return generateSetOperation(ast, options)
case 'UNKNOWN':
return 'unknown'
case 'CUSTOM_TYPE':
return ast.params
}
}
/**
* Generate a Union or Intersection
*/
function generateSetOperation(ast: TIntersection | TUnion, options: Options): string {
const members = (ast as TUnion).params.map(_ => generateType(_, options))
const separator = ast.type === 'UNION' ? '|' : '&'
if (members.length === 0) {
// A union of nothing accepts nothing (`never`). An intersection of nothing (eg. every
// `allOf` member turned out to contribute no information) constrains nothing -- render it
// exactly like the `{[k: string]: unknown}` a single vacuous member would otherwise have
// produced, so it still dedupes against an identical sibling rather than showing up as a
// spurious, differently-spelled extra member.
return ast.type === 'UNION' ? 'never' : generateInterface(vacuousInterface(options), options)
}
return members.length === 1 ? members[0] : '(' + members.join(' ' + separator + ' ') + ')'
}
function vacuousInterface(options: Options): TInterface {
return {
params: [
{
ast: options.unknownAny ? T_UNKNOWN_ADDITIONAL_PROPERTIES : T_ANY_ADDITIONAL_PROPERTIES,
isIndexSignature: true,
isPatternProperty: false,
isRequired: true,
isUnreachableDefinition: false,
keyName: '[k: string]',
},
],
superTypes: [],
type: 'INTERFACE',
}
}
// `any`/`unknown` accept every other type, making index-signature widening
// unnecessary. Checked on the AST (which covers named aliases, since the name is
// metadata on the same node); `tsType` overrides are compared textually since they
// are opaque.
function isAny(ast: AST): boolean {
return ast.type === 'ANY' || (ast.type === 'CUSTOM_TYPE' && ast.params.trim() === 'any')
}
function isUnknown(ast: AST): boolean {
return ast.type === 'UNKNOWN' || (ast.type === 'CUSTOM_TYPE' && ast.params.trim() === 'unknown')
}
/**
* Named properties (own and inherited) that TypeScript checks against an interface's
* index signature.
*/
function getIndexSignatureSiblings(
params: TInterfaceParam[],
indexSignature: TInterfaceParam,
superTypes: TNamedInterface[],
): TInterfaceParam[] {
const siblings = params.filter(_ => _ !== indexSignature)
const visited = new Set<TNamedInterface>()
function collectInherited(superTypes: TNamedInterface[]): void {
for (const superType of superTypes) {
if (visited.has(superType)) {
continue
}
visited.add(superType)
// the parser casts `extends` schemas to TNamedInterface unchecked, so a
// non-object supertype has no params to collect
if (superType.type !== 'INTERFACE') {
continue
}
siblings.push(
...superType.params.filter(_ => !_.isPatternProperty && !_.isUnreachableDefinition && !_.isIndexSignature),
)
collectInherited(superType.superTypes)
}
}
collectInherited(superTypes)
return siblings
}
// Types that render without recursing into other ASTs.
const LEAF_TYPES = new Set<AST['type']>([
'BOOLEAN',
'CUSTOM_TYPE',
'LITERAL',
'NEVER',
'NULL',
'NUMBER',
'OBJECT',
'REFERENCE',
'STRING',
])
/**
* TypeScript requires every named property's type to be assignable to the interface's
* index signature type (TS2411), including properties inherited via `extends`. Widen
* the index signature's type into a union that also covers the named properties'
* types — `T | undefined` for optional properties, since that is the type TypeScript
* checks them against.
*
* Operates on ASTs rather than generated strings so members are deduplicated and
* rendered by the normal generator machinery. Returns undefined when no widening is
* needed and the index signature should render its own type as usual.
*
* Known limitation: when a supertype declares its own index signature, widening the
* subtype's can make the two incompatible (TS2430) — that case needs narrowing, not
* widening, and is out of scope here.
*/
function generateIndexSignatureType(
indexSignature: TInterfaceParam,
params: TInterfaceParam[],
superTypes: TNamedInterface[],
options: Options,
): string | undefined {
if (isAny(indexSignature.ast) || isUnknown(indexSignature.ast)) {
return undefined
}
const memberASTs: AST[] = []
function addMember(ast: AST): void {
// flatten anonymous unions so their members participate in deduplication
if (ast.type === 'UNION' && !hasStandaloneName(ast)) {
ast.params.forEach(addMember)
return
}
// also flatten anonymous tsType unions, but only when provably safe to split:
// nothing but identifier characters, whitespace, and `|` (no brackets, quotes,
// arrows, or other constructs that would require real parsing)
if (
ast.type === 'CUSTOM_TYPE' &&
!hasStandaloneName(ast) &&
ast.params.includes('|') &&
/^[\w$.\s|]+$/.test(ast.params)
) {
for (const member of ast.params.split('|')) {
const trimmed = member.trim()
if (trimmed) {
memberASTs.push({type: 'CUSTOM_TYPE', params: trimmed})
}
}
return
}
memberASTs.push(ast)
}
addMember(indexSignature.ast)
let needsUndefined = options.strictIndexSignatures
for (const sibling of getIndexSignatureSiblings(params, indexSignature, superTypes)) {
if (isAny(sibling.ast)) {
// `any` (even when optional) is assignable to every index signature type
continue
}
if (!sibling.isRequired) {
needsUndefined = true
}
if (sibling.ast.type === 'NEVER') {
continue
}
addMember(sibling.ast)
}
// `unknown` absorbs every other member; so does an `any` among the index
// signature's own members that the optimizer did not already collapse (a
// `tsType: 'any'` patternProperty, say)
const top = memberASTs.some(isAny) ? 'any' : memberASTs.some(isUnknown) ? 'unknown' : undefined
if (top) {
return options.strictIndexSignatures ? `${top} | undefined` : top
}
// degenerate index signature type (e.g. an empty anyOf): render it as-is
if (memberASTs.length === 0) {
return undefined
}
// nothing to widen: keep the memoized as-is rendering
if (memberASTs.length === 1 && !needsUndefined) {
return undefined
}
const seen = new Set<string>()
const members: string[] = []
for (const memberAST of memberASTs) {
const type = generateType(memberAST, options)
// a named alias of a leaf type (e.g. `type Foo = string`) also covers its
// underlying type, so dedupe against both renderings. Restricted to leaf types
// because structurally rendering a compound type's body can re-enter an
// in-flight render for self-referential schemas (memoization only caches
// completed renders, so it cannot break such cycles). (generateRawType, not
// generateType: the name-stripped copy is a fresh object, so memoization
// can't help anyway.)
const underlying =
hasStandaloneName(memberAST) && LEAF_TYPES.has(memberAST.type)
? generateRawType(omitStandaloneName(memberAST), options)
: type
if (seen.has(type) || seen.has(underlying)) {
continue
}
seen.add(type)
seen.add(underlying)
// tsType overrides are opaque strings (e.g. function types) that may not be
// union-safe, so parenthesize them unless they are a simple type reference
members.push(memberAST.type === 'CUSTOM_TYPE' && !/^[\w$.]+(\[\])*$/.test(type) ? `(${type})` : type)
}
if (needsUndefined && !seen.has('undefined')) {
members.push('undefined')
}
return members.join(' | ')
}
function generateInterface(ast: TInterface, options: Options): string {
const params = ast.params.filter(_ => !_.isPatternProperty && !_.isUnreachableDefinition)
const indexSignature = params.find(_ => _.isIndexSignature)
const indexSignatureType = indexSignature
? generateIndexSignatureType(indexSignature, params, ast.superTypes, options)
: undefined
return (
`{` +
'\n' +
params
.map(param => {
const {isRequired, isIndexSignature, keyName, ast} = param
// the widened type handles strictIndexSignatures itself; the fallback path
// (widening skipped or unneeded) appends `| undefined` here
const type =
param === indexSignature && indexSignatureType !== undefined
? indexSignatureType
: generateType(ast, options) + (isIndexSignature && options.strictIndexSignatures ? ' | undefined' : '')
const commented = withItemsComment(ast)
return (
(hasComment(commented) && !ast.standaloneName
? generateComment(commented.comment, commented.deprecated) + '\n'
: '') +
(isIndexSignature ? keyName : escapeKeyName(keyName)) +
(isRequired ? '' : '?') +
': ' +
type
)
})
.join('\n') +
'\n' +
'}'
)
}
/**
* An inline (non-standalone) item schema is rendered mid-expression (`T[]`,
* `[T, ...T[]]`), where no statement line can carry a JSDoc block of its own, so
* its description is surfaced in the comment of the declaration the array type is
* attached to - a standalone type alias or an interface property - under an
* "Items:" label. This is the one place that decides where item descriptions go
* (#660).
*/
function withItemsComment<A extends AST>(ast: A): A {
const itemsComment = getItemsComment(ast)
if (itemsComment === undefined) {
return ast
}
return {...ast, comment: ast.comment ? ast.comment + '\n\n' + itemsComment : itemsComment}
}
function getItemsComment(ast: AST): string | undefined {
let members: AST[]
switch (ast.type) {
case 'ARRAY':
members = [ast.params]
break
case 'TUPLE':
members = ast.spreadParam ? [...ast.params, ast.spreadParam] : ast.params
break
default:
return undefined
}
// Named item types are declared separately with their own comment. Nested array
// types are skipped too: the normalizer appends `@minItems`/`@maxItems` block
// tags to their descriptions, which would read as tags of this declaration.
const comments = new Set(
members.map(_ => (hasStandaloneName(_) || _.type === 'ARRAY' || _.type === 'TUPLE' ? undefined : _.comment)),
)
// Every member has to carry the same description (for a tuple: one `items` schema
// that minItems/maxItems expanded). Distinct positional descriptions have no
// agreed rendering and are left out, as before.
const [comment] = comments
// TypeScript reads a JSDoc block tag (`@word` at line start or after whitespace)
// anywhere in a comment as a tag of the declaration the comment sits on, so a
// tagged item description (e.g. `@deprecated`) is not hoisted onto the array.
if (comments.size !== 1 || !comment || /(^|\s)@\w/.test(comment)) {
return undefined
}
return 'Items: ' + comment
}
function generateComment(comment?: string, deprecated?: boolean): string {
const commentLines = ['/**']
if (deprecated) {
commentLines.push(' * @deprecated')
}
if (typeof comment !== 'undefined') {
commentLines.push(...comment.split('\n').map(_ => ' * ' + _))
}
commentLines.push(' */')
return commentLines.join('\n')
}
function generateStandaloneEnum(ast: TEnum, options: Options): string {
// Anything that is not a bare TypeScript identifier has to be quoted. Testing
// for the valid shape (rather than for "special characters") also covers the
// empty string and names that begin with a digit, both of which are legal
// enum *values* but not legal identifiers.
const isValidIdentifier = (key: string): boolean => /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key)
return (
(hasComment(ast) ? generateComment(ast.comment, ast.deprecated) + '\n' : '') +
'export ' +
(options.enableConstEnums ? 'const ' : '') +
`enum ${toSafeString(ast.standaloneName)} {` +
'\n' +
ast.params
.map(
({ast, keyName}) =>
// JSON.stringify, not string interpolation: the key may itself contain
// quotes or backslashes that need escaping.
(isValidIdentifier(keyName) ? keyName : JSON.stringify(keyName)) + ' = ' + generateType(ast, options),
)
.join(',\n') +
'\n' +
'}'
)
}
function generateStandaloneInterface(ast: TNamedInterface, options: Options): string {
return (
(hasComment(ast) ? generateComment(ast.comment, ast.deprecated) + '\n' : '') +
`export interface ${toSafeString(ast.standaloneName)} ` +
(ast.superTypes.length > 0
? `extends ${ast.superTypes.map(superType => toSafeString(superType.standaloneName)).join(', ')} `
: '') +
generateInterface(ast, options)
)
}
function generateStandaloneType(ast: ASTWithStandaloneName, options: Options): string {
const commented = withItemsComment(ast)
return (
(hasComment(commented) ? generateComment(commented.comment) + '\n' : '') +
`export type ${toSafeString(ast.standaloneName)} = ${generateType(
omit<AST>(ast, 'standaloneName') as AST /* TODO */,
options,
)}`
)
}
function escapeKeyName(keyName: string): string {
if (keyName.length && /[A-Za-z_$]/.test(keyName.charAt(0)) && /^[\w$]+$/.test(keyName)) {
return keyName
}
return JSON.stringify(keyName)
}
function getSuperTypesAndParams(ast: TInterface): AST[] {
return ast.params.map(param => param.ast).concat(ast.superTypes)
}