UNPKG

json-schema-to-typescript

Version:
645 lines (590 loc) 21.9 kB
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) }