├── .babelrc ├── .eslintrc.yml ├── .gitignore ├── .travis.yml ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── bin └── ms2tsi ├── package.json ├── src ├── generate-interface.ts ├── generate-module.ts ├── ms2tsi.ts └── utilities.ts ├── test ├── generate-interface.spec.ts ├── generate-module.spec.ts ├── schemas │ ├── basic.schema.js │ ├── es2015.schema.js │ └── wrapped.schema.js └── utilities.spec.ts ├── tsconfig.json └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { "presets": ["es2015"] } 2 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | root: true 2 | parser: 'typescript-eslint-parser' 3 | parserOptions: 4 | sourceType: 'module' 5 | rules: 6 | semi: ['error', 'always'] 7 | quotes: ['error', 'single'] 8 | comma-dangle: ['error', { 9 | arrays: 'always-multiline', 10 | objects: 'always-multiline', 11 | imports: 'always-multiline', 12 | exports: 'always-multiline', 13 | functions: 'always-multiline' 14 | }] -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 18 | .grunt 19 | 20 | # node-waf configuration 21 | .lock-wscript 22 | 23 | # Compiled binary addons (http://nodejs.org/api/addons.html) 24 | build/Release 25 | 26 | # Dependency directory 27 | node_modules 28 | 29 | # Optional npm cache directory 30 | .npm 31 | 32 | # Optional REPL history 33 | .node_repl_history 34 | 35 | .DS_Store 36 | 37 | # Generated dist directory containing the final library code 38 | dist -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | notifications: 3 | email: false 4 | language: node_js 5 | cache: 6 | yarn: true 7 | directories: 8 | - node_modules 9 | node_js: 10 | - '9' 11 | - '8' 12 | before_install: yarn global add greenkeeper-lockfile@1 13 | before_script: greenkeeper-lockfile-update 14 | script: 15 | - yarn test 16 | - yarn build 17 | after_script: greenkeeper-lockfile-upload 18 | after_success: 19 | - yarn semantic-release 20 | branches: 21 | only: 22 | - master 23 | - /^greenkeeper/.*$/ -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "prettier.eslintIntegration": true 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 James Henry 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Mongoose Schema to TypeScript Interface

2 | 3 |

4 | Travis 5 | GitHub license 6 | NPM Version 7 | NPM Downloads 8 | Commitizen friendly 9 | semantic-release 10 | greenkeeper.io 11 |

12 | 13 |
14 |

15 | typescriptcourses.com 16 |

17 |
18 | 19 | **[Not yet ready for use]** Generates TypeScript interfaces from Mongoose Schemas 20 | -------------------------------------------------------------------------------- /bin/ms2tsi: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | require('../dist/ms2tsi.js') 3 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mongoose-schema-to-typescript-interface", 3 | "version": "0.0.0-semantically-released", 4 | "description": "Generates TypeScript interfaces from Mongoose Schemas", 5 | "main": "./dist/generate-interface.js", 6 | "scripts": { 7 | "test": "npm run build && jest", 8 | "build": "rm -rf dist/ && tsc", 9 | "semantic-release": 10 | "semantic-release pre && npm publish && semantic-release post", 11 | "precommit": "npm test && lint-staged", 12 | "cz": "git-cz" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": 17 | "https://github.com/JamesHenry/mongoose-schema-to-typescript-interface.git" 18 | }, 19 | "keywords": ["mongoose", "typescript", "schema", "interface"], 20 | "author": "James Henry ", 21 | "license": "MIT", 22 | "bugs": { 23 | "url": 24 | "https://github.com/JamesHenry/mongoose-schema-to-typescript-interface/issues" 25 | }, 26 | "homepage": 27 | "https://github.com/JamesHenry/mongoose-schema-to-typescript-interface#readme", 28 | "devDependencies": { 29 | "@types/jest": "21.1.8", 30 | "@types/mongoose": "4.7.29", 31 | "@types/node": "8.0.58", 32 | "cz-conventional-changelog": "2.1.0", 33 | "eslint": "4.13.1", 34 | "husky": "0.14.3", 35 | "jest": "21.2.1", 36 | "lint-staged": "6.0.0", 37 | "prettier-eslint-cli": "4.4.2", 38 | "semantic-release": "8.2.0", 39 | "ts-jest": "21.2.4", 40 | "typescript": "2.6.2", 41 | "typescript-eslint-parser": "10.0.0" 42 | }, 43 | "lint-staged": { 44 | "src/**/*": ["prettier-eslint --write", "git add"], 45 | "test/**/*": ["prettier-eslint --write", "git add"] 46 | }, 47 | "config": { 48 | "commitizen": { 49 | "path": "./node_modules/cz-conventional-changelog" 50 | } 51 | }, 52 | "bin": { 53 | "ms2tsi": "./bin/ms2tsi" 54 | }, 55 | "dependencies": { 56 | "babel-preset-es2015": "6.24.1", 57 | "babel-register": "6.26.0", 58 | "commander": "2.12.2", 59 | "lodash.camelcase": "4.3.0", 60 | "lodash.upperfirst": "4.3.1", 61 | "mongoose": "4.13.7" 62 | }, 63 | "jest": { 64 | "transform": { 65 | "^.+\\.tsx?$": "ts-jest" 66 | }, 67 | "testRegex": "(/test/.*|(\\.|/)(test|spec))\\.ts$", 68 | "moduleFileExtensions": ["ts", "js", "json"] 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/generate-interface.ts: -------------------------------------------------------------------------------- 1 | import { VirtualType, Schema } from 'mongoose'; 2 | 3 | import { 4 | TYPESCRIPT_TYPES, 5 | MONGOOSE_SCHEMA_TYPES, 6 | INTERFACE_PREFIX, 7 | REF_PATH_DELIMITER, 8 | appendNewline, 9 | indent, 10 | typeArrayOf, 11 | } from './utilities'; 12 | 13 | const camelCase = require('lodash.camelcase'); 14 | const upperFirst = require('lodash.upperfirst'); 15 | 16 | /** 17 | * Format a given nested interface name 18 | * @private 19 | */ 20 | function formatNestedInterfaceName(name: string): string { 21 | return upperFirst(camelCase(name)); 22 | } 23 | 24 | /** 25 | * Return true if the given mongoose field config is a nested schema object 26 | * @private 27 | */ 28 | function isNestedSchemaType(fieldConfig: any): boolean { 29 | return typeof fieldConfig === 'object' && !fieldConfig.type; 30 | } 31 | 32 | /** 33 | * Return true if the given mongoose field config is an array of nested schema objects 34 | * @private 35 | */ 36 | function isNestedSchemaArrayType(fieldConfig: any): boolean { 37 | return ( 38 | Array.isArray(fieldConfig.type) && 39 | fieldConfig.type.every((nestedConfig: any) => 40 | isNestedSchemaType(nestedConfig), 41 | ) 42 | ); 43 | } 44 | 45 | /** 46 | * Return true if the given mongoose field config is an instance of VirtualType 47 | * @private 48 | */ 49 | function isVirtualType(fieldConfig: any): boolean { 50 | // In some cases fieldConfig will not pass true for instanceof, do some additional duck typing 51 | const looksLikeVirtualType = 52 | fieldConfig && 53 | fieldConfig.path && 54 | Array.isArray(fieldConfig.getters) && 55 | Array.isArray(fieldConfig.setters) && 56 | typeof fieldConfig.options === 'object'; 57 | return fieldConfig instanceof VirtualType || looksLikeVirtualType; 58 | } 59 | 60 | /** 61 | * Return true if the given mongoose field config has enum values 62 | * @private 63 | */ 64 | function hasEnumValues(fieldConfig: any): boolean { 65 | return fieldConfig.enum && fieldConfig.enum.length; 66 | } 67 | 68 | /** 69 | * If the provided schema has already been instantiated with mongoose, 70 | * use the `tree` definition as the schema config 71 | * @private 72 | */ 73 | function getSchemaConfig(rawSchema: any): any { 74 | // In some cases rawSchema will not pass true for instanceof, do some additional duck typing 75 | if (rawSchema instanceof Schema || rawSchema.tree) { 76 | return rawSchema.tree; 77 | } 78 | return rawSchema; 79 | } 80 | 81 | /** 82 | * Convert an array of strings into a stringified TypeScript string literal type 83 | * @private 84 | */ 85 | function generateStringLiteralTypeFromEnum(enumOptions: string[]): string { 86 | let stringLiteralStr = ''; 87 | 88 | enumOptions.forEach((option, index) => { 89 | stringLiteralStr += `'${option}'`; 90 | 91 | if (index !== enumOptions.length - 1) { 92 | stringLiteralStr += ' | '; 93 | } 94 | }); 95 | 96 | return stringLiteralStr; 97 | } 98 | 99 | /** 100 | * Return a string representing the determined TypeScript type, if supported, 101 | * otherwise return the value of TYPESCRIPT_TYPES.UNSUPPORTED 102 | * @private 103 | */ 104 | function determineSupportedType(mongooseType: any): string { 105 | if (!mongooseType) { 106 | return TYPESCRIPT_TYPES.UNSUPPORTED; 107 | } 108 | 109 | switch (true) { 110 | case mongooseType === String: 111 | return TYPESCRIPT_TYPES.STRING; 112 | 113 | case mongooseType.schemaName === MONGOOSE_SCHEMA_TYPES.OBJECT_ID: 114 | case mongooseType.name === MONGOOSE_SCHEMA_TYPES.OBJECT_ID: 115 | return 'OBJECT_ID'; 116 | 117 | case mongooseType === Number: 118 | return TYPESCRIPT_TYPES.NUMBER; 119 | 120 | case mongooseType.schemaName === MONGOOSE_SCHEMA_TYPES.MIXED: 121 | return TYPESCRIPT_TYPES.OBJECT_LITERAL; 122 | 123 | case mongooseType === Date: 124 | return TYPESCRIPT_TYPES.DATE; 125 | 126 | case mongooseType === Boolean: 127 | return TYPESCRIPT_TYPES.BOOLEAN; 128 | 129 | case Array.isArray(mongooseType) === true: 130 | return TYPESCRIPT_TYPES.ARRAY; 131 | 132 | case typeof mongooseType === 'object' && 133 | Object.keys(mongooseType).length > 0: 134 | return TYPESCRIPT_TYPES.SCHEMA; 135 | 136 | default: 137 | return TYPESCRIPT_TYPES.UNSUPPORTED; 138 | } 139 | } 140 | 141 | /** 142 | * Predicate function to filter out invalid fields from a schema object 143 | * @private 144 | */ 145 | function filterOutInvalidFields(fieldName: string) { 146 | return fieldName !== '0' && fieldName !== '1'; 147 | } 148 | 149 | /** 150 | * Generate a field value (type definition) for a particular TypeScript interface 151 | * @private 152 | */ 153 | function generateInterfaceFieldValue( 154 | supportedType: string, 155 | fieldConfig: any, 156 | ): string { 157 | switch (supportedType) { 158 | /** 159 | * Single values 160 | */ 161 | case TYPESCRIPT_TYPES.NUMBER: 162 | case TYPESCRIPT_TYPES.OBJECT_LITERAL: 163 | case TYPESCRIPT_TYPES.DATE: 164 | case TYPESCRIPT_TYPES.BOOLEAN: 165 | return supportedType; 166 | 167 | /** 168 | * Strings and string literals 169 | */ 170 | case TYPESCRIPT_TYPES.STRING: 171 | if (hasEnumValues(fieldConfig)) { 172 | return generateStringLiteralTypeFromEnum(fieldConfig.enum); 173 | } 174 | return supportedType; 175 | } 176 | return ''; 177 | } 178 | 179 | /** 180 | * For the `rawSchema`, generate a TypeScript interface under the given `interfaceName`, 181 | * and any requisite nested interfaces 182 | * @public 183 | */ 184 | export default function typescriptInterfaceGenerator( 185 | interfaceName: string, 186 | rawSchema: any, 187 | refMapping: any = {}, 188 | ): string { 189 | let generatedContent = ''; 190 | 191 | function generateInterface(name: string, fromSchema: any) { 192 | const fields = Object.keys(fromSchema).filter(filterOutInvalidFields); 193 | let interfaceString = `interface ${INTERFACE_PREFIX}${name} {`; 194 | 195 | if (fields.length) { 196 | interfaceString = appendNewline(interfaceString); 197 | } 198 | 199 | fields.forEach(fieldName => { 200 | const fieldConfig = fromSchema[fieldName]; 201 | 202 | // VirtualType fields are not supported yet 203 | if (isVirtualType(fieldConfig)) { 204 | return null; 205 | } 206 | 207 | interfaceString += indent(fieldName); 208 | 209 | let supportedType: string; 210 | 211 | if (isNestedSchemaType(fieldConfig)) { 212 | supportedType = TYPESCRIPT_TYPES.OBJECT_LITERAL; 213 | } else { 214 | supportedType = determineSupportedType(fieldConfig.type); 215 | } 216 | 217 | /** 218 | * Unsupported type 219 | */ 220 | if (supportedType === TYPESCRIPT_TYPES.UNSUPPORTED) { 221 | throw new Error( 222 | `Mongoose type not recognised/supported: ${JSON.stringify( 223 | fieldConfig, 224 | )}`, 225 | ); 226 | } 227 | 228 | let interfaceVal: string = ''; 229 | 230 | /** 231 | * Nested schema type 232 | */ 233 | if (supportedType === TYPESCRIPT_TYPES.OBJECT_LITERAL) { 234 | if ( 235 | fieldConfig.type && 236 | fieldConfig.type.schemaName === MONGOOSE_SCHEMA_TYPES.MIXED 237 | ) { 238 | interfaceVal = '{}'; 239 | } else { 240 | const nestedInterfaceName = 241 | formatNestedInterfaceName(name) + 242 | INTERFACE_PREFIX + 243 | formatNestedInterfaceName(fieldName); 244 | const nestedSchemaConfig = getSchemaConfig(fieldConfig); 245 | const nestedInterface = generateInterface( 246 | nestedInterfaceName, 247 | nestedSchemaConfig, 248 | ); 249 | 250 | generatedContent += appendNewline(nestedInterface); 251 | 252 | interfaceVal = INTERFACE_PREFIX + nestedInterfaceName; 253 | } 254 | } else if (supportedType === TYPESCRIPT_TYPES.ARRAY) { 255 | /** 256 | * Empty array 257 | */ 258 | if (!fieldConfig.type.length) { 259 | interfaceVal = typeArrayOf(TYPESCRIPT_TYPES.ANY); 260 | } else if (isNestedSchemaArrayType(fieldConfig)) { 261 | const nestedSchemaConfig = getSchemaConfig(fieldConfig.type[0]); 262 | let nestedSupportedType = determineSupportedType(nestedSchemaConfig); 263 | 264 | if (nestedSupportedType === TYPESCRIPT_TYPES.UNSUPPORTED) { 265 | throw new Error( 266 | `Mongoose type not recognised/supported: ${JSON.stringify( 267 | fieldConfig, 268 | )}`, 269 | ); 270 | } 271 | 272 | /** 273 | * Nested Mixed types 274 | */ 275 | if (nestedSupportedType === TYPESCRIPT_TYPES.OBJECT_LITERAL) { 276 | interfaceVal = typeArrayOf( 277 | generateInterfaceFieldValue(nestedSupportedType, fieldConfig), 278 | ); 279 | } else { 280 | /** 281 | * Array of nested schema types 282 | */ 283 | const nestedInterfaceName = 284 | formatNestedInterfaceName(name) + 285 | INTERFACE_PREFIX + 286 | formatNestedInterfaceName(fieldName); 287 | const nestedInterface = generateInterface( 288 | nestedInterfaceName, 289 | nestedSchemaConfig, 290 | ); 291 | 292 | generatedContent += appendNewline(nestedInterface); 293 | 294 | interfaceVal = typeArrayOf(INTERFACE_PREFIX + nestedInterfaceName); 295 | } 296 | } else { 297 | /** 298 | * Array of single value types 299 | */ 300 | let nestedSupportedType = determineSupportedType(fieldConfig.type[0]); 301 | if (nestedSupportedType === TYPESCRIPT_TYPES.UNSUPPORTED) { 302 | throw new Error( 303 | `Mongoose type not recognised/supported: ${JSON.stringify( 304 | fieldConfig, 305 | )}`, 306 | ); 307 | } 308 | 309 | if (nestedSupportedType === 'OBJECT_ID') { 310 | if (fieldConfig.ref) { 311 | refMapping[ 312 | INTERFACE_PREFIX + name + REF_PATH_DELIMITER + fieldName 313 | ] = 314 | fieldConfig.ref; 315 | } 316 | nestedSupportedType = TYPESCRIPT_TYPES.STRING; 317 | } 318 | 319 | interfaceVal = typeArrayOf( 320 | generateInterfaceFieldValue(nestedSupportedType, fieldConfig), 321 | ); 322 | } 323 | } else { 324 | if (supportedType === 'OBJECT_ID') { 325 | if (fieldConfig.ref) { 326 | refMapping[ 327 | INTERFACE_PREFIX + name + REF_PATH_DELIMITER + fieldName 328 | ] = 329 | fieldConfig.ref; 330 | } 331 | supportedType = TYPESCRIPT_TYPES.STRING; 332 | } 333 | 334 | /** 335 | * Single value types 336 | */ 337 | interfaceVal = generateInterfaceFieldValue(supportedType, fieldConfig); 338 | } 339 | 340 | if ( 341 | !isNestedSchemaType(fieldConfig) && 342 | !isNestedSchemaArrayType(fieldConfig) && 343 | !fieldConfig.required 344 | ) { 345 | interfaceString += TYPESCRIPT_TYPES.OPTIONAL_PROP; 346 | } 347 | 348 | interfaceString += `: ${interfaceVal}`; 349 | 350 | interfaceString += ';'; 351 | 352 | interfaceString = appendNewline(interfaceString); 353 | }); 354 | 355 | interfaceString += appendNewline('}'); 356 | 357 | return interfaceString; 358 | } 359 | 360 | const schemaConfig = getSchemaConfig(rawSchema); 361 | const mainInterface = generateInterface(interfaceName, schemaConfig); 362 | 363 | generatedContent += appendNewline(mainInterface); 364 | 365 | return generatedContent; 366 | } 367 | -------------------------------------------------------------------------------- /src/generate-module.ts: -------------------------------------------------------------------------------- 1 | import { indentEachLine, appendNewline } from './utilities'; 2 | 3 | /** 4 | * Wrap the given stringified contents in a stringified TypeScript module, 5 | * using the given module name 6 | * @public 7 | */ 8 | export default function typescriptModuleGenerator( 9 | moduleName: string, 10 | moduleContents: string, 11 | ) { 12 | if (!moduleName) { 13 | throw new Error('"moduleName" is required to generate a TypeScript module'); 14 | } 15 | let typescriptModule = appendNewline(`declare module ${moduleName} {`); 16 | typescriptModule += indentEachLine(moduleContents); 17 | typescriptModule += appendNewline('}'); 18 | return typescriptModule; 19 | } 20 | -------------------------------------------------------------------------------- /src/ms2tsi.ts: -------------------------------------------------------------------------------- 1 | import * as program from 'commander'; 2 | import * as fs from 'fs'; 3 | import * as path from 'path'; 4 | 5 | import { generateOutput } from './utilities'; 6 | 7 | /** 8 | * Use Babel to transpile schema files 9 | */ 10 | import 'babel-register'; 11 | 12 | program.on('--help', () => { 13 | console.log(' Examples:'); 14 | console.log(''); 15 | console.log(' $ ms2tsi -h'); 16 | console.log( 17 | ' $ ms2tsi generate --module-name myModule --output-dir ./interfaces ./**/schema.js', 18 | ); 19 | console.log(''); 20 | }); 21 | 22 | program 23 | .command('generate [schemas...]') 24 | .option( 25 | '-o, --output-dir ', 26 | 'Where should the generated .d.ts file be written to?', 27 | ) 28 | .option( 29 | '-m, --module-name ', 30 | 'What should the TypeScript module and filename be?', 31 | ) 32 | .option('-e, --extend-refs', 'Experimental: Should refs be extended?') 33 | .action((schemas: string[], cmd: any) => { 34 | const { outputDir, moduleName, extendRefs } = cmd; 35 | 36 | if (!outputDir) { 37 | console.error('An output directory is required. Use -o or --output-dir'); 38 | return process.exit(1); 39 | } 40 | 41 | if (!moduleName) { 42 | console.error('A module name is required. Use -m or --module-name'); 43 | return process.exit(1); 44 | } 45 | 46 | if (!schemas || !schemas.length) { 47 | console.error( 48 | 'No schema files could be found. Please check the required usage by running `ms2tsi -h`', 49 | ); 50 | return process.exit(1); 51 | } 52 | 53 | const currentDir = process.env.PWD as string; 54 | const resolvedSchemaFiles = schemas.map((schemaPath: string) => { 55 | const schemaFile = require(path.resolve(currentDir, schemaPath)); 56 | /** 57 | * Allow for vanilla objects or full mongoose.Schema instances to 58 | * have been exported by normalising the exported `schema` property 59 | */ 60 | if (schemaFile.schema && schemaFile.schema.tree) { 61 | schemaFile.schema = schemaFile.schema.tree; 62 | return schemaFile; 63 | } 64 | return schemaFile; 65 | }); 66 | 67 | const output = generateOutput( 68 | moduleName, 69 | currentDir, 70 | resolvedSchemaFiles, 71 | extendRefs, 72 | ); 73 | 74 | fs.writeFileSync( 75 | path.resolve(currentDir, `${outputDir}/${moduleName}.d.ts`), 76 | output, 77 | ); 78 | }); 79 | 80 | program.parse(process.argv); 81 | -------------------------------------------------------------------------------- /src/utilities.ts: -------------------------------------------------------------------------------- 1 | import generateModule from './generate-module'; 2 | import generateInterface from './generate-interface'; 3 | 4 | /** 5 | * Library constants 6 | */ 7 | export const INDENT_CHAR = '\t'; 8 | export const NEWLINE_CHAR = '\n'; 9 | export const INTERFACE_PREFIX = 'I'; 10 | export const REF_PATH_DELIMITER = '::'; 11 | export const TYPESCRIPT_TYPES = { 12 | STRING: 'string', 13 | NUMBER: 'number', 14 | BOOLEAN: 'boolean', 15 | ARRAY: 'Array', 16 | DATE: 'Date', 17 | OBJECT_LITERAL: '{}', 18 | ANY: 'any', 19 | ARRAY_THEREOF: '[]', 20 | OPTIONAL_PROP: '?', 21 | UNSUPPORTED: 'Unsupported', 22 | SCHEMA: 'SCHEMA', 23 | }; 24 | export const MONGOOSE_SCHEMA_TYPES = { 25 | OBJECT_ID: 'ObjectId', 26 | MIXED: 'Mixed', 27 | }; 28 | 29 | export function typeArrayOf(str: string): string { 30 | return `Array<${str}>`; 31 | } 32 | 33 | /** 34 | * Append the newline character to a given string 35 | */ 36 | export function appendNewline(str: string): string { 37 | return `${str}${NEWLINE_CHAR}`; 38 | } 39 | 40 | /** 41 | * Prepend a given string with the indentation character 42 | */ 43 | export function indent(str: string): string { 44 | return `${INDENT_CHAR}${str}`; 45 | } 46 | 47 | /** 48 | * Split on the newline character and prepend each of the 49 | * resulting strings with the indentation character 50 | */ 51 | export function indentEachLine(content: string): string { 52 | return content 53 | .split(NEWLINE_CHAR) 54 | .map(line => { 55 | /** 56 | * Do not indent a line which purely consists of 57 | * a newline character 58 | */ 59 | if (line.length) { 60 | return indent(line); 61 | } 62 | 63 | return line; 64 | }) 65 | .join(NEWLINE_CHAR); 66 | } 67 | 68 | export function generateOutput( 69 | moduleName: string, 70 | _currentDir: string, 71 | schemaFiles: any[], 72 | extendRefs: boolean = false, 73 | ): string { 74 | let output = ''; 75 | const refMapping = {}; 76 | 77 | for (const schemaFile of schemaFiles) { 78 | const interfaceName = schemaFile.name; 79 | const schemaTree = schemaFile.schema; 80 | 81 | if (!interfaceName) { 82 | throw new Error(`Schema file does not export a 'name': ${schemaFile}`); 83 | } 84 | 85 | output += generateInterface(interfaceName, schemaTree, refMapping); 86 | } 87 | 88 | if (extendRefs) { 89 | output = extendRefTypes(output, refMapping); 90 | } 91 | 92 | output = generateModule(moduleName, output); 93 | 94 | return output; 95 | } 96 | 97 | function stripInterface(str: string): string { 98 | return str.replace('interface ', '').replace(' {', ''); 99 | } 100 | 101 | export function extendRefTypes( 102 | generatedOutput: string, 103 | refMapping: any = {}, 104 | ): string { 105 | const refPaths = Object.keys(refMapping); 106 | if (!refPaths || !refPaths.length) { 107 | return generatedOutput; 108 | } 109 | 110 | let updatedOutput = generatedOutput; 111 | 112 | refPaths.forEach(refPath => { 113 | const [interfaceName, propertyName] = refPath.split(REF_PATH_DELIMITER); 114 | const refValue = refMapping[refPath]; 115 | const targetInterfaceStartRegexp = new RegExp( 116 | `interface ${interfaceName} {`, 117 | ); 118 | 119 | /** 120 | * Check the given output for an applicable interface for the refValue 121 | */ 122 | const refInterfaceStartRegexp = new RegExp( 123 | `interface ${INTERFACE_PREFIX}${refValue} {`, 124 | ); 125 | const foundInterface = updatedOutput.match(refInterfaceStartRegexp); 126 | if (!foundInterface) { 127 | return null; 128 | } 129 | 130 | const matchingReferencedInterfaceName: string = stripInterface( 131 | foundInterface[0], 132 | ); 133 | 134 | /** 135 | * Split the given output by line 136 | */ 137 | const outputLines = updatedOutput.split('\n'); 138 | 139 | /** 140 | * Locate the start of the target interface 141 | */ 142 | let startIndexOfTargetInterface: number; 143 | for (let i = 0; i < outputLines.length; i++) { 144 | if (outputLines[i].match(targetInterfaceStartRegexp)) { 145 | startIndexOfTargetInterface = i; 146 | break; 147 | } 148 | } 149 | 150 | // @ts-ignore 151 | if (typeof startIndexOfTargetInterface !== 'number') { 152 | return null; 153 | } 154 | 155 | let endIndexOfTargetInterface: number; 156 | for (let i = startIndexOfTargetInterface; i < outputLines.length; i++) { 157 | if (outputLines[i].indexOf('}') > -1) { 158 | endIndexOfTargetInterface = i; 159 | break; 160 | } 161 | } 162 | 163 | const refPropertyRegexp = new RegExp(`${propertyName}: string;`); 164 | const updatedLines = outputLines.map((line, index) => { 165 | if ( 166 | index > startIndexOfTargetInterface && 167 | index < endIndexOfTargetInterface 168 | ) { 169 | const targetFieldDefinition = line.match(refPropertyRegexp); 170 | if (targetFieldDefinition) { 171 | return line.replace( 172 | 'string;', 173 | `string | ${matchingReferencedInterfaceName};`, 174 | ); 175 | } 176 | } 177 | 178 | return line; 179 | }); 180 | 181 | updatedOutput = updatedLines.join('\n'); 182 | }); 183 | 184 | return updatedOutput; 185 | } 186 | -------------------------------------------------------------------------------- /test/generate-interface.spec.ts: -------------------------------------------------------------------------------- 1 | import { Schema } from 'mongoose'; 2 | 3 | import generateInterface from '../src/generate-interface'; 4 | 5 | describe('generateInterface', () => { 6 | it('should return a stringified TypeScript interface', () => { 7 | const input = generateInterface('EmptyInterface', {}); 8 | const output = `interface IEmptyInterface {} 9 | 10 | `; 11 | 12 | expect(input).toEqual(output); 13 | }); 14 | 15 | it('should convert mongoose \'type: ObjectId\' to TypeScript type \'string\'', () => { 16 | const input = generateInterface('ObjectIdInterface', { 17 | id: { 18 | type: Schema.Types.ObjectId, 19 | required: true, 20 | }, 21 | }); 22 | 23 | const output = `interface IObjectIdInterface { 24 | id: string; 25 | } 26 | 27 | `; 28 | 29 | expect(input).toEqual(output); 30 | }); 31 | 32 | it('should convert mongoose \'required: false\' to TypeScript optional property syntax', () => { 33 | const input = generateInterface('OptionalPropInterface', { 34 | id: { 35 | type: Schema.Types.ObjectId, 36 | required: false, 37 | }, 38 | }); 39 | 40 | const output = `interface IOptionalPropInterface { 41 | id?: string; 42 | } 43 | 44 | `; 45 | 46 | expect(input).toEqual(output); 47 | }); 48 | 49 | it('should convert mongoose \'type: Mixed\' to TypeScript type \'{}\'', () => { 50 | const input = generateInterface('MixedInterface', { 51 | id: { 52 | type: Schema.Types.Mixed, 53 | required: true, 54 | }, 55 | }); 56 | 57 | const output = `interface IMixedInterface { 58 | id: {}; 59 | } 60 | 61 | `; 62 | 63 | expect(input).toEqual(output); 64 | }); 65 | 66 | it('should convert mongoose \'type: String\' to TypeScript type \'string\'', () => { 67 | const input = generateInterface('NameStringInterface', { 68 | name: { 69 | type: String, 70 | required: true, 71 | }, 72 | }); 73 | 74 | const output = `interface INameStringInterface { 75 | name: string; 76 | } 77 | 78 | `; 79 | 80 | expect(input).toEqual(output); 81 | }); 82 | 83 | it('should convert mongoose \'type: String\' and \'enum: [...]\' to TypeScript \'string literal type\'', () => { 84 | const input = generateInterface('StringOptionsInterface', { 85 | chosen_letter: { 86 | type: String, 87 | enum: ['a', 'b', 'c'], 88 | required: true, 89 | }, 90 | }); 91 | 92 | const output = `interface IStringOptionsInterface { 93 | chosen_letter: 'a' | 'b' | 'c'; 94 | } 95 | 96 | `; 97 | 98 | expect(input).toEqual(output); 99 | }); 100 | 101 | it('should convert mongoose \'type: Number\' to TypeScript type \'number\'', () => { 102 | const input = generateInterface('AgeNumberInterface', { 103 | age: { 104 | type: Number, 105 | required: true, 106 | }, 107 | }); 108 | 109 | const output = `interface IAgeNumberInterface { 110 | age: number; 111 | } 112 | 113 | `; 114 | 115 | expect(input).toEqual(output); 116 | }); 117 | 118 | it('should convert mongoose \'type: Number\' to TypeScript type \'number\'', () => { 119 | const input = generateInterface('AgeNumberInterface', { 120 | age: { 121 | type: Number, 122 | required: true, 123 | }, 124 | }); 125 | 126 | const output = `interface IAgeNumberInterface { 127 | age: number; 128 | } 129 | 130 | `; 131 | 132 | expect(input).toEqual(output); 133 | }); 134 | 135 | it('should convert mongoose \'type: Boolean\' to TypeScript type \'boolean\'', () => { 136 | const input = generateInterface('EnabledBooleanInterface', { 137 | enabled: { 138 | type: Boolean, 139 | required: true, 140 | }, 141 | }); 142 | 143 | const output = `interface IEnabledBooleanInterface { 144 | enabled: boolean; 145 | } 146 | 147 | `; 148 | 149 | expect(input).toEqual(output); 150 | }); 151 | 152 | it('should convert mongoose \'type: Date\' to TypeScript type \'Date\'', () => { 153 | const input = generateInterface('StartInterface', { 154 | start: { 155 | type: Date, 156 | required: true, 157 | }, 158 | }); 159 | 160 | const output = `interface IStartInterface { 161 | start: Date; 162 | } 163 | 164 | `; 165 | 166 | expect(input).toEqual(output); 167 | }); 168 | 169 | it('should convert mongoose \'type: []\' to TypeScript type \'Array\'', () => { 170 | const input = generateInterface('AnyListInterface', { 171 | stuff: { 172 | type: [], 173 | required: true, 174 | }, 175 | }); 176 | 177 | const output = `interface IAnyListInterface { 178 | stuff: Array; 179 | } 180 | 181 | `; 182 | 183 | expect(input).toEqual(output); 184 | }); 185 | 186 | it('should convert mongoose \'type: [Number]\' to TypeScript type \'Array\'', () => { 187 | const input = generateInterface('NumberListInterface', { 188 | list: { 189 | type: [Number], 190 | required: true, 191 | }, 192 | }); 193 | 194 | const output = `interface INumberListInterface { 195 | list: Array; 196 | } 197 | 198 | `; 199 | 200 | expect(input).toEqual(output); 201 | }); 202 | 203 | it('should convert mongoose \'type: [String]\' to TypeScript type \'Array\'', () => { 204 | const input = generateInterface('NameListInterface', { 205 | names: { 206 | type: [String], 207 | required: true, 208 | }, 209 | }); 210 | 211 | const output = `interface INameListInterface { 212 | names: Array; 213 | } 214 | 215 | `; 216 | 217 | expect(input).toEqual(output); 218 | }); 219 | 220 | it('should convert mongoose \'type: [Boolean]\' to TypeScript type \'Array\'', () => { 221 | const input = generateInterface('StatusListInterface', { 222 | statuses: { 223 | type: [Boolean], 224 | required: true, 225 | }, 226 | }); 227 | 228 | const output = `interface IStatusListInterface { 229 | statuses: Array; 230 | } 231 | 232 | `; 233 | 234 | expect(input).toEqual(output); 235 | }); 236 | 237 | it('should convert mongoose \'type: [Date]\' to TypeScript type \'Array\'', () => { 238 | const input = generateInterface('DateListInterface', { 239 | dates: { 240 | type: [Date], 241 | required: true, 242 | }, 243 | }); 244 | 245 | const output = `interface IDateListInterface { 246 | dates: Array; 247 | } 248 | 249 | `; 250 | 251 | expect(input).toEqual(output); 252 | }); 253 | 254 | it('should convert mongoose \'type: [ObjectId]\' to TypeScript type \'Array\'', () => { 255 | const input = generateInterface('ObjectIdListInterface', { 256 | ids: { 257 | type: [Schema.Types.ObjectId], 258 | required: true, 259 | }, 260 | }); 261 | 262 | const output = `interface IObjectIdListInterface { 263 | ids: Array; 264 | } 265 | 266 | `; 267 | 268 | expect(input).toEqual(output); 269 | }); 270 | 271 | it('should convert mongoose \'type: [Mixed]\' to TypeScript type \'Array<{}>\'', () => { 272 | const input = generateInterface('MixedListInterface', { 273 | id: { 274 | type: [Schema.Types.Mixed], 275 | required: true, 276 | }, 277 | }); 278 | 279 | const output = `interface IMixedListInterface { 280 | id: Array<{}>; 281 | } 282 | 283 | `; 284 | 285 | expect(input).toEqual(output); 286 | }); 287 | 288 | it('should dynamically create any nested mongoose schemas as TypeScript interfaces', () => { 289 | const input = generateInterface('MainInterface', { 290 | nested: { 291 | stuff: { 292 | type: String, 293 | required: false, 294 | }, 295 | }, 296 | }); 297 | 298 | const output = `interface IMainInterfaceINested { 299 | stuff?: string; 300 | } 301 | 302 | interface IMainInterface { 303 | nested: IMainInterfaceINested; 304 | } 305 | 306 | `; 307 | 308 | expect(input).toEqual(output); 309 | }); 310 | 311 | it('should format nested schema names as TitleCase', () => { 312 | const input = generateInterface('MainInterface', { 313 | snake_case: { 314 | stuff: { 315 | type: String, 316 | required: false, 317 | }, 318 | }, 319 | }); 320 | 321 | const output = `interface IMainInterfaceISnakeCase { 322 | stuff?: string; 323 | } 324 | 325 | interface IMainInterface { 326 | snake_case: IMainInterfaceISnakeCase; 327 | } 328 | 329 | `; 330 | 331 | expect(input).toEqual(output); 332 | }); 333 | 334 | it('should support multiple schema fields on newlines', () => { 335 | const input = generateInterface('MultipleFieldsInterface', { 336 | field1: { 337 | type: String, 338 | required: true, 339 | }, 340 | field2: { 341 | type: String, 342 | required: true, 343 | }, 344 | }); 345 | 346 | const output = `interface IMultipleFieldsInterface { 347 | field1: string; 348 | field2: string; 349 | } 350 | 351 | `; 352 | 353 | expect(input).toEqual(output); 354 | }); 355 | 356 | it('should support arrays of nested schemas as a field type', () => { 357 | const nested = { 358 | stuff: { 359 | type: String, 360 | required: false, 361 | }, 362 | }; 363 | 364 | const input = generateInterface('MainInterface', { 365 | multipleNested: { 366 | type: [nested], 367 | }, 368 | }); 369 | 370 | const output = `interface IMainInterfaceIMultipleNested { 371 | stuff?: string; 372 | } 373 | 374 | interface IMainInterface { 375 | multipleNested: Array; 376 | } 377 | 378 | `; 379 | 380 | expect(input).toEqual(output); 381 | }); 382 | 383 | it('should process a complex example', () => { 384 | const nestedSchema = new Schema( 385 | { 386 | thing: { 387 | type: Schema.Types.ObjectId, 388 | ref: 'Thing', 389 | }, 390 | }, 391 | {}, 392 | ); 393 | 394 | const nestedItemSchema = new Schema( 395 | { 396 | user: { 397 | type: Schema.Types.ObjectId, 398 | ref: 'User', 399 | required: true, 400 | }, 401 | priority: { 402 | type: Number, 403 | required: true, 404 | default: 0, 405 | }, 406 | }, 407 | { 408 | _id: false, 409 | id: false, 410 | }, 411 | ); 412 | 413 | const mainSchema: any = new Schema( 414 | { 415 | name: { 416 | type: String, 417 | required: true, 418 | index: true, 419 | placeholder: 'PLACEHOLDER_NAME', 420 | }, 421 | referencedDocument: { 422 | type: Schema.Types.ObjectId, 423 | ref: 'RefDoc', 424 | immutable: true, 425 | required: true, 426 | }, 427 | stringOptionsWithDefault: { 428 | type: String, 429 | required: true, 430 | default: 'defaultVal', 431 | enum: ['defaultVal', 'Option2'], 432 | }, 433 | setting_type: { 434 | type: String, 435 | }, 436 | setting_value: { 437 | type: Number, 438 | min: 0, 439 | validate: function validate(val: any) { 440 | if (this.setting_type === 'foo') { 441 | if (val > 1) { 442 | return false; 443 | } 444 | } 445 | return true; 446 | }, 447 | }, 448 | enabled: { 449 | type: Boolean, 450 | required: true, 451 | default: false, 452 | }, 453 | nestedSchema: nestedSchema, 454 | nestedInline: { 455 | prop: { 456 | type: Number, 457 | required: true, 458 | }, 459 | }, 460 | nestedEmptyInline: {}, 461 | nestedItems: { 462 | type: [nestedItemSchema], 463 | }, 464 | }, 465 | { 466 | strict: true, 467 | }, 468 | ); 469 | 470 | mainSchema.foo = 'bar'; 471 | mainSchema.searchable = true; 472 | mainSchema.index({ 473 | name: 'text', 474 | }); 475 | 476 | const input = generateInterface('MainInterface', mainSchema); 477 | 478 | const output = `interface IMainInterfaceINestedSchema { 479 | thing?: string; 480 | _id?: string; 481 | } 482 | 483 | interface IMainInterfaceINestedInline { 484 | prop: number; 485 | } 486 | 487 | interface IMainInterfaceINestedEmptyInline {} 488 | 489 | interface IMainInterfaceINestedItems { 490 | user: string; 491 | priority: number; 492 | } 493 | 494 | interface IMainInterface { 495 | name: string; 496 | referencedDocument: string; 497 | stringOptionsWithDefault: 'defaultVal' | 'Option2'; 498 | setting_type?: string; 499 | setting_value?: number; 500 | enabled: boolean; 501 | nestedSchema: IMainInterfaceINestedSchema; 502 | nestedInline: IMainInterfaceINestedInline; 503 | nestedEmptyInline: IMainInterfaceINestedEmptyInline; 504 | nestedItems: Array; 505 | _id?: string; 506 | } 507 | 508 | `; 509 | 510 | expect(input).toEqual(output); 511 | }); 512 | }); 513 | -------------------------------------------------------------------------------- /test/generate-module.spec.ts: -------------------------------------------------------------------------------- 1 | import generateModule from '../src/generate-module'; 2 | 3 | describe('generateModule', () => { 4 | it('should return a stringified TypeScript module', () => { 5 | const input = generateModule('ModuleName', ''); 6 | const output = `declare module ModuleName { 7 | } 8 | `; 9 | 10 | expect(input).toEqual(output); 11 | }); 12 | 13 | it('should name the TypeScript module based on the given name', () => { 14 | const input = generateModule('GivenModuleName', ''); 15 | const output = `declare module GivenModuleName { 16 | } 17 | `; 18 | 19 | expect(input).toEqual(output); 20 | }); 21 | 22 | it('should wrap the given stringified content in a stringified TypeScript module', () => { 23 | const input = generateModule( 24 | 'ModuleName', 25 | `interface IEmptyInterface {} 26 | `, 27 | ); 28 | const output = `declare module ModuleName { 29 | interface IEmptyInterface {} 30 | } 31 | `; 32 | 33 | expect(input).toEqual(output); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /test/schemas/basic.schema.js: -------------------------------------------------------------------------------- 1 | exports.name = 'basic'; 2 | 3 | exports.schema = { 4 | name: { 5 | type: String, 6 | required: true, 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /test/schemas/es2015.schema.js: -------------------------------------------------------------------------------- 1 | let name = 'es2015'; 2 | 3 | exports.name = name; 4 | 5 | exports.schema = { 6 | start: { 7 | type: Date, 8 | required: true, 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /test/schemas/wrapped.schema.js: -------------------------------------------------------------------------------- 1 | const Schema = require('mongoose').Schema; 2 | 3 | const wrappedSchema = new Schema( 4 | { 5 | age: { 6 | type: Number, 7 | }, 8 | }, 9 | { 10 | strict: true, 11 | }, 12 | ); 13 | 14 | exports.name = 'wrapped'; 15 | 16 | exports.schema = wrappedSchema; 17 | -------------------------------------------------------------------------------- /test/utilities.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | NEWLINE_CHAR, 3 | INDENT_CHAR, 4 | REF_PATH_DELIMITER, 5 | appendNewline, 6 | indent, 7 | indentEachLine, 8 | extendRefTypes, 9 | } from '../src/utilities'; 10 | 11 | describe('utilities', () => { 12 | describe('appendNewline', () => { 13 | it('should append a newline character to the given string', () => { 14 | expect(appendNewline('test string')).toEqual( 15 | 'test string' + NEWLINE_CHAR, 16 | ); 17 | }); 18 | }); 19 | 20 | describe('indent', () => { 21 | it('should prepend an indent character to the given string', () => { 22 | expect(indent('test string')).toEqual(INDENT_CHAR + 'test string'); 23 | }); 24 | }); 25 | 26 | describe('indentEachLine', () => { 27 | it('should prepend an indent character to each line of the given string', () => { 28 | expect( 29 | indentEachLine(` 30 | test1 31 | test2 32 | test3 33 | `), 34 | ).toEqual(` 35 | ${INDENT_CHAR}test1 36 | ${INDENT_CHAR}test2 37 | ${INDENT_CHAR}test3 38 | `); 39 | }); 40 | }); 41 | 42 | describe('extendRefTypes', () => { 43 | it('should scan the generated output for matching refs and extend the type annotation', () => { 44 | const refMapping = { 45 | [`IMainInterface${REF_PATH_DELIMITER}propWithRef`]: 'OtherThing', 46 | }; 47 | 48 | const generatedOutput = `interface IOtherThing { 49 | foo: string; 50 | } 51 | 52 | interface IMainInterface { 53 | propWithRef: string; 54 | bar: number; 55 | } 56 | 57 | `; 58 | const expected = `interface IOtherThing { 59 | foo: string; 60 | } 61 | 62 | interface IMainInterface { 63 | propWithRef: string | IOtherThing; 64 | bar: number; 65 | } 66 | 67 | `; 68 | 69 | expect(extendRefTypes(generatedOutput, refMapping)).toEqual(expected); 70 | }); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "removeComments": true, 7 | "emitDecoratorMetadata": true, 8 | "experimentalDecorators": true, 9 | "sourceMap": false, 10 | "pretty": true, 11 | "allowUnreachableCode": false, 12 | "noFallthroughCasesInSwitch": false, 13 | /** 14 | * TODO: Fix issues 15 | */ 16 | // "noImplicitReturns": true, 17 | "noImplicitAny": true, 18 | "strictNullChecks": true, 19 | "noUnusedParameters": true, 20 | "noUnusedLocals": true, 21 | "skipLibCheck": true, 22 | "skipDefaultLibCheck": true, 23 | "outDir": "./dist", 24 | "types": ["node"], 25 | "lib": ["es2015"] 26 | }, 27 | "include": ["src/**/*.ts"], 28 | "exclude": ["node_modules"] 29 | } 30 | --------------------------------------------------------------------------------