diff --git a/src/__tests__/main_alt.test.ts b/src/__tests__/main_alt.test.ts new file mode 100644 index 0000000..8d3ed14 --- /dev/null +++ b/src/__tests__/main_alt.test.ts @@ -0,0 +1,174 @@ +import { format, resolveConfig } from 'prettier'; +import { Project, SourceFile } from 'ts-morph'; +import { generateRuntypes } from '../main_alt'; + +async function fmt(source: string) { + const config = await resolveConfig(__filename); + return format(source, config); +} + +describe('runtype generation', () => { + let project: Project; + let file: SourceFile; + + beforeEach(() => { + project = new Project(); + file = project.createSourceFile('./test.ts'); + }); + + it('smoke test', async () => { + generateRuntypes( + file, + + { + name: 'personRt', + export: false, + type: { + kind: 'record', + fields: [ + { name: 'name', readonly: true, type: { kind: 'string' } }, + { name: 'age', readonly: true, type: { kind: 'number' } }, + ], + }, + }, + + { + export: true, + name: 'smokeTest', + type: { + kind: 'record', + fields: [ + { name: 'someBoolean', type: { kind: 'boolean' } }, + { name: 'someNever', type: { kind: 'never' } }, + { name: 'someNumber', type: { kind: 'number' } }, + { name: 'someString', type: { kind: 'string' } }, + { name: 'someUnknown', type: { kind: 'unknown' } }, + { name: 'someVoid', type: { kind: 'void' } }, + { + name: 'someLiteral1', + type: { kind: 'literal', value: 'string' }, + }, + { name: 'someLiteral2', type: { kind: 'literal', value: 1337 } }, + { name: 'someLiteral3', type: { kind: 'literal', value: true } }, + { name: 'someLiteral4', type: { kind: 'literal', value: null } }, + { + name: 'someLiteral5', + type: { kind: 'literal', value: undefined }, + }, + { + name: 'someDictionary', + type: { kind: 'dictionary', valueType: { kind: 'boolean' } }, + }, + { + name: 'someArray', + type: { kind: 'array', type: { kind: 'string' }, readonly: true }, + }, + { + name: 'someNamedType', + type: { kind: 'named', name: 'personRt' }, + }, + { + name: 'someIntersection', + type: { + kind: 'intersect', + types: [ + { + kind: 'record', + fields: [{ name: 'member1', type: { kind: 'string' } }], + }, + { + kind: 'record', + fields: [{ name: 'member2', type: { kind: 'number' } }], + }, + ], + }, + }, + { + name: 'someObject', + type: { + kind: 'record', + fields: [ + { name: 'name', readonly: true, type: { kind: 'string' } }, + { name: 'age', readonly: true, type: { kind: 'number' } }, + { + name: 'medals', + readonly: true, + type: { + kind: 'union', + types: [ + { kind: 'literal', value: '1' }, + { kind: 'literal', value: '2' }, + { kind: 'literal', value: '3' }, + { kind: 'literal', value: 'last' }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ); + const raw = file.getText(); + const formatted = await fmt(raw); + expect(formatted).toMatchInlineSnapshot(` + "const personRt = rt.Record({ + name: rt.String, + age: rt.Number, + }); + export const smokeTest = rt.Record({ + someBoolean: rt.Boolean, + someNever: rt.Never, + someNumber: rt.Number, + someString: rt.String, + someUnknown: rt.Unknown, + someVoid: rt.Void, + someLiteral1: rt.Literal('string'), + someLiteral2: rt.Literal(1337), + someLiteral3: rt.Literal(true), + someLiteral4: rt.Literal(null), + someLiteral5: rt.Literal(undefined), + someDictionary: rt.Dictionary(rt.Boolean), + someArray: rt.Array(rt.String).asReadonly(), + someNamedType: personRt, + someIntersection: rt.Intersect( + rt.Record({ + member1: rt.String, + }), + rt.Record({ + member2: rt.Number, + }), + ), + someObject: rt.Record({ + name: rt.String, + age: rt.Number, + medals: rt.Union( + rt.Literal('1'), + rt.Literal('2'), + rt.Literal('3'), + rt.Literal('last'), + ), + }), + }); + " + `); + }); + + it.todo('Array'); + it.todo('Boolean'); + it.todo('Brand'); + it.todo('Constrant'); + it.todo('Dictionary'); + it.todo('Function'); + it.todo('Literal'); + it.todo('Never'); + it.todo('Number'); + it.todo('Record'); + it.todo('String'); + it.todo('Symbol'); + it.todo('Tuple'); + it.todo('Union'); + it.todo('Unknown'); + it.todo('Void'); +}); diff --git a/src/main_alt.ts b/src/main_alt.ts new file mode 100644 index 0000000..b3715e5 --- /dev/null +++ b/src/main_alt.ts @@ -0,0 +1,123 @@ +import { + CodeBlockWriter, + OptionalKind, + SourceFile, + VariableDeclarationKind, + VariableStatementStructure, +} from 'ts-morph'; +import { + AnyType, + ArrayType, + DictionaryType, + LiteralType, + NamedType, + RecordType, + RootType, + UnionType, +} from './types_alt'; + +export function generateRuntypes(file: SourceFile, ...roots: RootType[]): void { + file.addVariableStatements( + roots.map>((root) => { + return { + isExported: root.export, + declarationKind: VariableDeclarationKind.Const, + declarations: [ + { name: root.name, initializer: (w) => writeAnyType(w, root.type) }, + ], + }; + }), + ); +} + +// fixme: use mapped type so `node` is typed more narrowly maybe +const writers: Record< + AnyType['kind'], + (writer: CodeBlockWriter, node: AnyType) => void +> = { + boolean: simpleWriter('rt.Boolean'), + function: simpleWriter('rt.Function'), + never: simpleWriter('rt.Never'), + number: simpleWriter('rt.Number'), + string: simpleWriter('rt.String'), + unknown: simpleWriter('rt.Unknown'), + void: simpleWriter('rt.Void'), + array: writeArrayType, + record: writeRecordType, + union: writeUnionType, + literal: writeLiteralType, + named: writeNamedType, + intersect: writeIntersectionType, + dictionary: writeDictionaryType, +}; + +function simpleWriter(value: string): (writer: CodeBlockWriter) => void { + return (writer) => writer.write(value); +} + +function writeDictionaryType(w: CodeBlockWriter, node: DictionaryType) { + w.write('rt.Dictionary('); + writeAnyType(w, node.valueType); + w.write(')'); +} + +function writeNamedType(w: CodeBlockWriter, node: NamedType) { + w.write(node.name); +} + +function writeAnyType(w: CodeBlockWriter, node: AnyType) { + const writer = writers[node.kind]; + writer(w, node); +} + +function writeLiteralType(w: CodeBlockWriter, node: LiteralType) { + const { value } = node; + w.write('rt.Literal('); + if (value === undefined) { + w.write('undefined'); + } else if (value === null) { + w.write('null'); + } else if (typeof value === 'string') { + w.write(`'${value}'`); + } else { + // It's a boolean or a number at this point. + w.write(String(value)); + } + w.write(')'); +} + +function writeArrayType(w: CodeBlockWriter, node: ArrayType) { + w.write('rt.Array('); + writeAnyType(w, node.type); + w.write(')'); + w.conditionalWrite(node.readonly, '.asReadonly()'); +} + +function writeUnionType(w: CodeBlockWriter, node: UnionType) { + w.writeLine('rt.Union('); + for (const type of node.types) { + writeAnyType(w, type); + w.write(', '); + } + w.write(') '); +} + +function writeIntersectionType(w: CodeBlockWriter, node: UnionType) { + w.writeLine('rt.Intersect('); + for (const type of node.types) { + writeAnyType(w, type); + w.write(', '); + } + w.write(') '); +} + +function writeRecordType(w: CodeBlockWriter, node: RecordType) { + w.writeLine('rt.Record({'); + for (const field of node.fields) { + w.write(field.name); + w.write(': '); + writeAnyType(w, field.type); + w.write(','); + } + w.write('})'); +} diff --git a/src/types_alt.ts b/src/types_alt.ts new file mode 100644 index 0000000..2092675 --- /dev/null +++ b/src/types_alt.ts @@ -0,0 +1,166 @@ +import * as rt from 'runtypes'; + +/** + * Types that don't need any extra configuration + */ +const simpleTypeRt = rt.Record({ + kind: rt.Union( + rt.Literal('boolean'), + rt.Literal('function'), + rt.Literal('never'), + rt.Literal('number'), + rt.Literal('string'), + rt.Literal('unknown'), + rt.Literal('void'), + ), +}); + +export type SimpleType = rt.Static; + +/** + * Same as: + * rt.Literal(value) + */ +const literalTypeRt = rt.Record({ + kind: rt.Literal('literal'), + value: rt.Union(rt.Boolean, rt.Null, rt.Number, rt.String, rt.Undefined), +}); + +export type LiteralType = rt.Static; + +/** + * Same as using an already defined runtype. such as how "personRt" is used here:: + * + * const personRt = rt.Record({ name: rt.String }); + * const people = rt.Array(personRt); + * + */ +const namedTypeRt = rt.Record({ + kind: rt.Literal('named'), + name: rt.String, +}); + +export type NamedType = rt.Static; + +export type RecordField = { + name: string; + type: AnyType; + readonly?: boolean; + nullable?: boolean; +}; + +export type RecordType = { + kind: 'record'; + fields: RecordField[]; +}; + +/** + * Same as rt.Record({...}) + */ +const recordTypeRt: rt.Runtype = rt.Lazy(() => + rt.Record({ + kind: rt.Literal('record'), + fields: rt.Array( + rt.Record({ + name: rt.String, + readonly: rt.Boolean, + nullable: rt.Boolean, + type: anyTypeRt, + }), + ), + }), +); + +export type ArrayType = { + kind: 'array'; + type: AnyType; + readonly?: boolean; +}; + +/** + * Same as rt.Array(type) + */ +const arrayTypeRt: rt.Runtype = rt.Lazy(() => + rt.Record({ + kind: rt.Literal('array'), + type: anyTypeRt, + readonly: rt.Boolean, + }), +); + +export type DictionaryType = { + kind: 'dictionary'; + valueType: AnyType; +}; + +/** + * Same as rt.Dictionary(valueType) + */ +const dictionaryTypeRt: rt.Runtype = rt.Lazy(() => + rt.Record({ + kind: rt.Literal('dictionary'), + valueType: anyTypeRt, + }), +); + +export type UnionType = { + kind: 'union'; + types: AnyType[]; +}; + +/** + * Same as rt.Union(type1, type2) + */ +const unionTypeRt: rt.Runtype = rt.Lazy(() => + rt.Record({ + kind: rt.Literal('union'), + types: rt + .Array(anyTypeRt) + .withConstraint( + (e) => + e.length <= 20 || `Union can have at most 20 types. Got ${e.length}.`, + ), + }), +); + +export type IntersectionType = { + kind: 'intersect'; + types: AnyType[]; +}; + +/** + * Same as rt.Intersect(type1, type2) + */ +const intersectionTypeRt: rt.Runtype = rt.Lazy(() => + rt.Record({ + kind: rt.Literal('intersect'), + types: rt + .Array(anyTypeRt) + .withConstraint( + (e) => + e.length <= 10 || + `Intersection can have at most 10 types. Got ${e.length}.`, + ), + }), +); + +const anyTypeRt = rt.Union( + arrayTypeRt, + dictionaryTypeRt, + intersectionTypeRt, + literalTypeRt, + namedTypeRt, + recordTypeRt, + simpleTypeRt, + unionTypeRt, +); + +export type AnyType = rt.Static; + +export const rootType = rt.Record({ + name: rt.String, + export: rt.Boolean, + type: anyTypeRt, +}); + +export type RootType = rt.Static; diff --git a/tsconfig.json b/tsconfig.json index b612191..6afd4d5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "module": "commonjs", "declaration": true, - "noImplicitAny": false, + "noImplicitAny": true, "importHelpers": true, "removeComments": true, "noLib": false,