├── .gitignore ├── jest.config.js ├── tsconfig.json ├── package.json ├── src ├── interfaces.ts ├── index.test.ts └── index.ts └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | index.d.ts 3 | dist 4 | testing.js 5 | testing.ts 6 | test-output -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: "ts-jest", 4 | testEnvironment: "node", 5 | }; 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "commonjs", 5 | "declaration": true, 6 | "outDir": "dist", 7 | "strict": true, 8 | "esModuleInterop": true 9 | }, 10 | "include": ["src/**/*"], 11 | "exclude": ["node_modules", "**/*.test.ts"] 12 | } 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "type-genius", 3 | "version": "0.0.13", 4 | "description": "", 5 | "main": "dist/index.js", 6 | "author": "Mike Carbone", 7 | "repository": { 8 | "url": "https://github.com/MikeCarbone/type-genius" 9 | }, 10 | "scripts": { 11 | "build": "tsc", 12 | "prepare": "tsc", 13 | "test": "jest" 14 | }, 15 | "license": "ISC", 16 | "types": "dist/index.d.ts", 17 | "dependencies": { 18 | "deep-equal": "^2.2.0", 19 | "uppercamelcase": "^3.0.0" 20 | }, 21 | "files": [ 22 | "dist" 23 | ], 24 | "devDependencies": { 25 | "@types/deep-equal": "^1.0.1", 26 | "@types/jest": "^29.4.0", 27 | "@types/node": "^18.14.1", 28 | "@types/uppercamelcase": "^3.0.0", 29 | "jest": "^29.4.3", 30 | "ts-jest": "^29.0.5", 31 | "typescript": "^4.9.5" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/interfaces.ts: -------------------------------------------------------------------------------- 1 | export interface ValueTypeConfiguration { 2 | type: string; 3 | optional: boolean; 4 | isArray: boolean; 5 | object_keys?: TypeConfigurationObject; 6 | } 7 | 8 | export interface TypeConfigurationObject { 9 | [key: string]: ValueTypeConfiguration; 10 | } 11 | 12 | export interface InterfaceConfig { 13 | string: string; 14 | typesConfig: TypeConfigurationObject; 15 | interfaceName: string; 16 | } 17 | 18 | export type TypeStore = InterfaceConfig[]; 19 | 20 | export interface CreateInterfaceOptions extends BuildOptions { 21 | interfaceName?: string; 22 | nested?: string[]; 23 | } 24 | 25 | export interface BuildOptions { 26 | /** 27 | * Customize the types that get rendered. 28 | */ 29 | customTypes?: { 30 | object?: string; 31 | string?: string; 32 | boolean?: string; 33 | number?: string; 34 | unknown?: string; 35 | }; 36 | /** 37 | * Forces each value in every type to be optional. 38 | */ 39 | forceOptional?: boolean; 40 | /** 41 | * The name given to the first generated interface. 42 | */ 43 | initialInterfaceName?: string; 44 | /** 45 | * Should a success message get rendered after successfully generating the types. 46 | */ 47 | logSuccess?: boolean; 48 | /** 49 | * File name to give the rendered types file. 50 | */ 51 | outputFilename?: string; 52 | /** 53 | * Where to render the generated types. 54 | */ 55 | outputPath?: string; 56 | /** 57 | * Render semicolons in the outputted file. 58 | */ 59 | renderSemis?: boolean; 60 | /** 61 | * Whether to write the file or not. 62 | */ 63 | skipFileWrite?: boolean; 64 | /** 65 | * Store of existing InterfaceConfiguration objects to use for this generation. 66 | */ 67 | useStore?: TypeStore; 68 | /** 69 | * Whether to render "type"s instead of "interface"s. 70 | */ 71 | useTypes?: boolean; 72 | } 73 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect, describe } from "@jest/globals"; 2 | import { buildTypes, buildTypesFileString } from "."; 3 | 4 | const testObj = { 5 | a: 1, 6 | b: true, 7 | c: { 8 | d: "three", 9 | }, 10 | }; 11 | 12 | const testObj2 = { 13 | test: { 14 | obj: { 15 | f: { 16 | 1: "2", 17 | 2: 3, 18 | 3: [1, 2, 3], 19 | }, 20 | g: { 21 | 1: "2", 22 | 2: 3, 23 | 3: [1, 2, 3], 24 | }, 25 | h: { 26 | 1: "2", 27 | 2: 3, 28 | 3: [1, 2, 3], 29 | }, 30 | }, 31 | }, 32 | }; 33 | 34 | describe("custom options are working correctly", () => { 35 | const build = buildTypes(testObj, { 36 | useTypes: true, 37 | renderSemis: true, 38 | skipFileWrite: true, 39 | initialInterfaceName: "Runner", 40 | outputPath: "../../", 41 | forceOptional: true, 42 | customTypes: { 43 | string: "test", 44 | }, 45 | }); 46 | 47 | test("ensures functioning useTypes option", () => { 48 | const val = build[0].string.slice(7, 11); 49 | expect(val).toBe("type"); 50 | }); 51 | 52 | test("ensures functioning renderSemis option", () => { 53 | const val = build[0].string.charAt(28); 54 | expect(val).toBe(";"); 55 | }); 56 | 57 | test("ensures functioning initialInterfaceName option", () => { 58 | const val = build[1].string.slice(12, 18); 59 | expect(val).toBe("Runner"); 60 | }); 61 | 62 | test("ensures functioning forceOptional option", () => { 63 | const val = build[1].typesConfig.a.optional; 64 | expect(val).toBe(true); 65 | }); 66 | 67 | test("ensures functioning customType option", () => { 68 | const val = build[0].typesConfig.d.type; 69 | expect(val).toBe("test"); 70 | }); 71 | }); 72 | 73 | describe("default options are working correctly", () => { 74 | const build = buildTypes(testObj, { skipFileWrite: true }); 75 | const build2 = buildTypes(testObj2, { skipFileWrite: true }); 76 | 77 | test("ensures deep interface naming is correct ", () => { 78 | const val = build2[1].string.slice(17, 24); 79 | expect(val).toBe("TestObj"); 80 | }); 81 | 82 | test("ensures interfaces rendering", () => { 83 | const val = build[0].string.slice(7, 16); 84 | expect(val).toBe("interface"); 85 | }); 86 | 87 | test("ensures correct number type", () => { 88 | const val = build[1].typesConfig.a.type; 89 | expect(val).toBe("number"); 90 | }); 91 | 92 | test("ensures correct string type", () => { 93 | const val = build[0].typesConfig.d.type; 94 | expect(val).toBe("string"); 95 | }); 96 | 97 | test("ensures correct boolean type", () => { 98 | const val = build[1].typesConfig.b.type; 99 | expect(val).toBe("boolean"); 100 | }); 101 | 102 | test("ensures correct object type", () => { 103 | const val = build[1].typesConfig.c.type; 104 | expect(val).toBe("object"); 105 | }); 106 | 107 | test("ensures InterfaceConfig has all properties", () => { 108 | const val = Object.keys(build[0]).length; 109 | expect(val).toBe(3); 110 | }); 111 | }); 112 | 113 | describe("buildTypesFileString function", () => { 114 | const str = buildTypesFileString(testObj); 115 | 116 | test("ensures function returns a string", () => { 117 | const val = typeof str; 118 | expect(val).toBe("string"); 119 | }); 120 | }); 121 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Type Genius 2 | 3 | [Demo](https://type-genius.carbonology.in/) 4 | 5 | ## What is it 6 | 7 | Type Genius is a library that can generate a Typescript file from any JSON object. 8 | 9 | This generator can be useful for many reasons: 10 | 11 | - Creating interfaces for JSON data returned from HTTP requests 12 | - Migrating JavaScript code to Typescript 13 | - Quickly scaffolding Typescript interfaces based on existing data structures 14 | 15 | For example, the object returned from an HTTP request can be _anything_, but generally, it's going to be consistent in its return. It would be great to leverage Typescript to have intellisense for the response object. However, many APIs don't ship with a Typescript library, so you have to assume `any` as its type, or type it by hand. 16 | 17 | On the other hand, you can use this package to quickly generate an interface from an API response. 18 | 19 | ```ts 20 | import { buildTypes } from "type-genius"; 21 | 22 | // Get some data 23 | const res = await fetch("https://json.com"); 24 | const data = await res.json(); 25 | 26 | // Generate type file 27 | buildTypes(data); 28 | ``` 29 | 30 | ## Options 31 | 32 | The `buildTypes` function takes a second parameter where you can pass an options object. Below are the expected keys for that option object. 33 | 34 | | **Option Name** | **Type** | **Default** | **Description** | 35 | | -------------------- | ---------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | 36 | | customTypes | Object? | `js { string: "string", number: "number", boolean: "boolean", object: "object" } ` | Customize the types that get rendered. For objects, you can render a Record like this: `js customTypes: { object: "Record" ` | 37 | | forceOptional | Boolean? | false | Forces each value in every type to be optional. | 38 | | initialInterfaceName | String? | "Response" | The name given to the first generated interface. | 39 | | logSuccess | Boolean? | false | Should a success message get rendered after successfully generating the types. | 40 | | outputFilename | String? | "exported.d.ts" | File name to give the rendered types file. | 41 | | outputPath | String? | "../dist/" | Where to render the generated types. | 42 | | renderSemis | Boolean? | false | Render semicolons in the outputted file. | 43 | | skipFileWrite | Boolean? | false | Whether to write the file or not. | 44 | | useStore | TypeStore? | [] | Store of existing InterfaceConfiguration objects to use for this generation. | 45 | | useTypes | Boolean? | false | Whether to render "type"s instead of "interface"s. | 46 | 47 | ## Architecture 48 | 49 | Before we can create our Typescript file, we have to run through a few steps to make sure things run correctly. Here is what happens under the hood: 50 | 51 | ### 1. PARSE - Convert object to a type configuration object 52 | 53 | We first parse our object to determine each value's type. For example, this object: 54 | 55 | ```json 56 | { 57 | "key_name_1": "value", 58 | "key_name_2": 1, 59 | "key_name_3": { 60 | "key_name_4": true 61 | } 62 | } 63 | ``` 64 | 65 | will become this: 66 | 67 | ```json 68 | { 69 | "key_name_1": { 70 | "type": "string", 71 | "optional": false 72 | }, 73 | "key_name_2": { 74 | "type": "number", 75 | "optional": false 76 | }, 77 | "key_name_3": { 78 | "type": "object", 79 | "optional": false, 80 | "object_keys": { 81 | "key_name_4": { 82 | "type": "boolean", 83 | "optional": false 84 | } 85 | } 86 | } 87 | } 88 | ``` 89 | 90 | ### 2. SAVE - Initialize type store 91 | 92 | Soon we're going to create configuration objects that describe how to construct our interface. Before we do that, we need to save them somewhere so we can refer back to this list if we have to. We have to do this in order to remove duplicate interfaces. 93 | 94 | ```js 95 | const typesStore = []; 96 | ``` 97 | 98 | If you want to save interfaces generated in the past and refer back to them, you can use a populated array here. This is useful if you have recurring interfaces in multiple places. You don't always want to generate every interface from scratch. 99 | 100 | ### 3. CREATE - Populate store with interface configurations 101 | 102 | An interface configuration is a set of instructions that outline how to create each interface. It includes the name of the interface, the various type configuration objects within, and the generated file string. 103 | 104 | An interface configuration looks like this: 105 | 106 | ```js 107 | { 108 | "string": "", // string that will get written to a file 109 | "typesConfig": {}, // type configuration object 110 | "interfaceName": "" // name of the interface 111 | } 112 | ``` 113 | 114 | Here is how each interface configuration gets produced. 115 | First, let's assume our store is empty. The engine will go key-by-key through our type configuration object and generate the string necessary. 116 | 117 | ```json 118 | { 119 | "key_name_1": { 120 | "type": "string", 121 | "optional": false 122 | } 123 | } 124 | ``` 125 | 126 | will produce the string: 127 | 128 | ```typescript 129 | export interface Response { 130 | key_name_1: string; 131 | } 132 | ``` 133 | 134 | At this point the interface configuration is saved and stored. 135 | 136 | If one of the keys has a type of `object`, the function will run recursively to determine an interface for the nested object. For example, when the engine reaches a key like the one below, the function is going to rerun on the `object_keys` field: 137 | 138 | ```json 139 | { 140 | "key_name_3": { 141 | "type": "object", 142 | "optional": false, 143 | "object_keys": { 144 | "key_name_4": { 145 | "type": "boolean", 146 | "optional": false 147 | } 148 | } 149 | } 150 | } 151 | ``` 152 | 153 | #### Interface Resolution 154 | 155 | Each time the engine attempts to create an interface configuration, it will first do a deep comparison of its type configuration object against all existing interface configurations' type configuration objects already in the store. If it finds a match, it will return that interface and the key will now reference that interface. 156 | 157 | ### 4. EXPORT - Concatenate string properties from interface configurations 158 | 159 | At this stage, each interface configuration string is concatenated into a single string. This big string will get written to a file, and exported with the specified options. 160 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { writeFile } from "fs"; 2 | import { join } from "path"; 3 | import deepEqual from "deep-equal"; 4 | import upperCamel from "uppercamelcase"; 5 | 6 | import { 7 | type BuildOptions, 8 | type CreateInterfaceOptions, 9 | type InterfaceConfig, 10 | type TypeConfigurationObject, 11 | type TypeStore, 12 | type ValueTypeConfiguration, 13 | } from "./interfaces"; 14 | 15 | /** 16 | * Get the type of a value 17 | * This function will return a type configration object that is a type config 18 | * We assume everything is required. 19 | * If something is null or undefined, we can assume that value is not required in the object 20 | */ 21 | function getTypeConfig( 22 | value: unknown, 23 | options: BuildOptions 24 | ): ValueTypeConfiguration { 25 | const typeConfig: ValueTypeConfiguration = { 26 | type: "unknown", 27 | optional: !!options?.forceOptional, 28 | isArray: false, 29 | }; 30 | 31 | const types = { 32 | string: "string", 33 | number: "number", 34 | object: "object", 35 | boolean: "boolean", 36 | unknown: "unknown", 37 | ...options?.customTypes, 38 | }; 39 | 40 | if (typeof value === "string") { 41 | typeConfig.type = types.string; 42 | return typeConfig; 43 | } 44 | if (typeof value === "number") { 45 | typeConfig.type = types.number; 46 | return typeConfig; 47 | } 48 | if (typeof value === "boolean") { 49 | typeConfig.type = types.boolean; 50 | return typeConfig; 51 | } 52 | if (value === null) { 53 | typeConfig.type = types.unknown; 54 | typeConfig.optional = true; 55 | return typeConfig; 56 | } 57 | if (typeof value === "object") { 58 | // item is array 59 | if (Array.isArray(value)) { 60 | typeConfig.isArray = true; 61 | 62 | // If there is no value in the array, we can't be sure what it is, and we know its optional 63 | if (value.length === 0) { 64 | typeConfig.type = types.unknown; 65 | typeConfig.optional = true; 66 | } else { 67 | // We can figure out what's in the array based on the first value 68 | const arrayContentsType = getTypeConfig(value[0], options).type; 69 | 70 | // Set the newly determined type 71 | typeConfig.type = arrayContentsType; 72 | 73 | // If it's an object, we can run this recursively until everything is figured out 74 | if (arrayContentsType === "object") { 75 | typeConfig.object_keys = generateTypeConfigFromObject( 76 | value[0], 77 | options 78 | ); 79 | } 80 | } 81 | return typeConfig; 82 | } 83 | 84 | // If the object has no keys, so we don't want to try to figure out the nested type 85 | const hasKeys = !!Object.keys(value).length; 86 | 87 | // instead we just say it's an object 88 | typeConfig.type = types.object; 89 | 90 | // if it does have keys, figure out what's inside 91 | if (hasKeys) { 92 | typeConfig.object_keys = generateTypeConfigFromObject( 93 | value as { [key: string]: unknown }, 94 | options 95 | ); 96 | } 97 | 98 | return typeConfig; 99 | } 100 | // This shouldn't get hit 101 | return typeConfig; 102 | } 103 | 104 | /** 105 | * This function will break down an object's keys and return an object with its type configurations 106 | * Each key gets a new object as its value. That object describes the type to render. 107 | */ 108 | function generateTypeConfigFromObject( 109 | obj: { [key: string]: unknown }, 110 | options: BuildOptions 111 | ) { 112 | const typeConfigObject: TypeConfigurationObject = {}; 113 | Object.keys(obj).forEach((key) => { 114 | const val = obj[key]; 115 | typeConfigObject[key] = getTypeConfig(val, options); 116 | }); 117 | return typeConfigObject; 118 | } 119 | 120 | /** 121 | * This is how we write our string to a file 122 | * Called multiple places so making this explicit in one spot 123 | */ 124 | function generateKeyString({ 125 | key, 126 | type, 127 | isOptional, 128 | isArray, 129 | renderSemis, 130 | }: { 131 | key: string; 132 | type: string; 133 | isOptional: boolean; 134 | isArray: boolean; 135 | renderSemis?: boolean; 136 | }) { 137 | return ` ${key}${isOptional ? "?" : ""}: ${type}${isArray ? "[]" : ""}${ 138 | renderSemis ? ";" : "" 139 | }\n`; 140 | } 141 | 142 | /** 143 | * This function will actually render the types to a file 144 | */ 145 | function createInterface( 146 | typesConfig: TypeConfigurationObject, 147 | typesStore: TypeStore, 148 | options?: CreateInterfaceOptions 149 | ) { 150 | const interfaceName = 151 | options?.interfaceName || options?.initialInterfaceName || "Response"; 152 | const nested = options?.nested || []; 153 | 154 | // If the config is the same, skip generation, reference that interface 155 | // We use a deep comparison of the typesConfig object 156 | // Everything will have to be equal 157 | const match = typesStore 158 | .map((config) => { 159 | if (deepEqual(config.typesConfig, typesConfig)) { 160 | return config; 161 | } else { 162 | return null; 163 | } 164 | }) 165 | .find((v) => v !== null); 166 | if (match) return match; 167 | 168 | // This is the general object that will outline an interface construction 169 | // string is what gets written 170 | // typesConfig is what we use as comparison 171 | // interfaceName so we can refer back to it in the future 172 | const interfaceConfig: InterfaceConfig = { 173 | string: "", 174 | typesConfig, 175 | interfaceName, 176 | }; 177 | 178 | // Let's start the string for the interface 179 | let str = options?.useTypes 180 | ? `export type ${interfaceName} = {\n` 181 | : `export interface ${interfaceName} {\n`; 182 | 183 | // For each type configuration object (one per key, original object key is preserved) 184 | Object.keys(typesConfig).map((key) => { 185 | const config = typesConfig[key]; 186 | 187 | // If it's an object, we want to think about its types and handle those differently 188 | if (config.type === "object" && "object_keys" in config) { 189 | // We keep track of how nested this object is so we can determine a good name automatically 190 | const newNested = [...nested, key]; 191 | 192 | // Generate a new type name based on how nested it is 193 | // This may or may not get used, depending on if a match was found in our typesStore 194 | // If a match was found, we use that name to refer to existing interface 195 | const newTypeName = newNested.map((w) => upperCamel(w)).join(""); 196 | 197 | // This either returns an existing interfaceConfig or creates a new one 198 | const newOrExistingInterface = createInterface( 199 | config.object_keys || {}, 200 | typesStore, 201 | { 202 | ...options, 203 | interfaceName: newTypeName, 204 | nested: newNested, 205 | } 206 | ); 207 | 208 | // Let's render that key in our string using the proper interface 209 | str += generateKeyString({ 210 | key, 211 | type: newOrExistingInterface.interfaceName, 212 | isOptional: config.optional, 213 | isArray: config.isArray, 214 | renderSemis: options?.renderSemis, 215 | }); 216 | return str; 217 | } 218 | 219 | // For everything else that's not an object we can just use the type determined in our type configuration 220 | str += generateKeyString({ 221 | key, 222 | type: config.type, 223 | isOptional: config.optional, 224 | isArray: config.isArray, 225 | renderSemis: options?.renderSemis, 226 | }); 227 | return str; 228 | }); 229 | str += "}"; 230 | 231 | // Set the properties of our interfaceConfig 232 | interfaceConfig.string = str; 233 | interfaceConfig.typesConfig = typesConfig; 234 | interfaceConfig.interfaceName = interfaceName; 235 | 236 | // Save the interfaceConfig to our store 237 | typesStore.push(interfaceConfig); 238 | 239 | return interfaceConfig; 240 | } 241 | 242 | export function buildTypes( 243 | data: { [key: string]: unknown }, 244 | options?: BuildOptions 245 | ) { 246 | // Convert our object into our type configurations object 247 | const types = generateTypeConfigFromObject(data, options || {}); 248 | 249 | // Types store will be our saved types for a file 250 | // We can reuse these so future objects can reuse types or start from nothing to generate new ones 251 | const typesStore: TypeStore = options?.useStore || []; 252 | 253 | // Populate our store so we can write the interfaces to a file 254 | createInterface(types, typesStore, options); 255 | 256 | if (options?.skipFileWrite) { 257 | return typesStore; 258 | } 259 | 260 | // Flatten to interface configurations to a single string 261 | const fileString = typesStore.map((t) => t.string).join("\n\n") + "\n"; 262 | 263 | // Write the file using that string 264 | const outputPath = options?.outputPath || "../dist/"; 265 | const outputFilename = options?.outputFilename || "exported.d.ts"; 266 | writeFile( 267 | join(__dirname, outputPath, outputFilename), 268 | fileString, 269 | (err) => { 270 | if (err) { 271 | console.error("Could not complete file write.", err); 272 | } else { 273 | if (options?.logSuccess) { 274 | console.log("File written successfully!"); 275 | } 276 | } 277 | } 278 | ); 279 | return typesStore; 280 | } 281 | 282 | /** 283 | * This function is very similar to buildTypes above, but instead we explicitly return a string 284 | * This is so Typescript knows this one definitely returns a string, and the other definitely returns 285 | * a TypeStore 286 | */ 287 | export function buildTypesFileString( 288 | data: { [key: string]: unknown }, 289 | options?: BuildOptions 290 | ) { 291 | // Convert our object into our type configurations object 292 | const types = generateTypeConfigFromObject(data, options || {}); 293 | 294 | // Types store will be our saved types for a file 295 | // We can reuse these so future objects can reuse types or start from nothing to generate new ones 296 | const typesStore: TypeStore = options?.useStore || []; 297 | 298 | // Populate our store so we can write the interfaces to a file 299 | createInterface(types, typesStore, options); 300 | 301 | // Flatten to interface configurations to a single string 302 | const fileString = typesStore.map((t) => t.string).join("\n\n") + "\n"; 303 | 304 | return fileString; 305 | } 306 | 307 | export { 308 | type BuildOptions, 309 | type CreateInterfaceOptions, 310 | type InterfaceConfig, 311 | type TypeConfigurationObject, 312 | type TypeStore, 313 | type ValueTypeConfiguration, 314 | }; 315 | --------------------------------------------------------------------------------