Skip to content

Instantly share code, notes, and snippets.

@skurfuerst
Created January 16, 2021 21:10
Show Gist options
  • Save skurfuerst/a07ab23c3e40a45f2268f7700ceeceaf to your computer and use it in GitHub Desktop.
Save skurfuerst/a07ab23c3e40a45f2268f7700ceeceaf to your computer and use it in GitHub Desktop.
Generate runtypes from TypeScript definitions using ts-morph
// RUN with a recent node.js version, using "node gen-types.mjs"
// DEPENDS on typescript and ts-morph
import { Project } from "ts-morph";
import { writeFileSync } from "fs";
const project = new Project({
tsConfigFilePath: "tsconfig.json",
});
// 1) DEFINITION which types to extract
const buildInstructions = [
{
targetFile: 'lib/api/gitlab/types/Users.gen.ts',
sourceTypes: [
{
file: 'node_modules/@gitbeaker/core/dist/types/services/Users.d.ts',
type: 'UserDetailSchemaDefault',
overrideTypes: {
created_at: 'String'
}
}
]
},
{
targetFile: 'lib/api/gitlab/types/Pipelines.gen.ts',
sourceTypes: [
{
file: 'node_modules/@gitbeaker/core/dist/types/services/Pipelines.d.ts',
type: 'PipelineSchemaDefault',
overrideTypes: {
created_at: 'String',
updated_at: 'String'
}
}
]
},
];
// 2) print SEMANTIC ERRORS detected by the TypeScript compiler, as they might influence the result.
const diagnostics = project.getPreEmitDiagnostics();
// there might be errors in our generated files; so we ignore them (as they will be overridden anyways)
const allTargetFiles = buildInstructions.map(buildInstruction => buildInstruction.targetFile);
const filteredDiagnostics = diagnostics.filter(diagnostic => !allTargetFiles.some(targetFile => diagnostic.getSourceFile().getFilePath().includes(targetFile)));
console.log(project.formatDiagnosticsWithColorAndContext(filteredDiagnostics));
// 3) start the RUNTYPE GENERATION
const generatedFilePreamble = `
// this file is AUTOMATICALLY GENERATED by scripts/build-runtypes-from-ts-types.mjs.
// It includes Runtypes definitions for TypeScript types; so you can validate them at runtime:
//
// To regenerate the file, run "yarn gen-types"
`;
// per output file, we have one buildInstruction.
buildInstructions.forEach(buildInstruction => {
// to build up the `import {...} from 'runtypes'` line, we collect the variables we need in scope.
let requiredImportsFromRuntype = {};
const typeDeclarations = buildInstruction.sourceTypes.map(sourceType => {
const typeDeclaration = project.getSourceFileOrThrow(sourceType.file).getInterfaceOrThrow(sourceType.type);
const comment = `// This runtime type is automatically generated from ${sourceType.type} in ${sourceType.file}\n`;
const runtype = `export const ${sourceType.type} = ${renderType(typeDeclaration.getType(), 0, sourceType.overrideTypes || {}, requiredImportsFromRuntype)};\n`;
requiredImportsFromRuntype['Static'] = true;
const staticType = `export type ${sourceType.type}Type = Static<typeof ${sourceType.type}>;`;
return comment + runtype + staticType;
});
const importStatement = `\nimport { ${Object.keys(requiredImportsFromRuntype).sort().join(', ')} } from "runtypes";\n\n`;
const fileContents = generatedFilePreamble + importStatement + typeDeclarations.join("\n\n");
writeFileSync(buildInstruction.targetFile, fileContents, "utf8");
console.log(`Generated ${buildInstruction.targetFile}`);
});
console.log("All done!");
// Helpers
/**
* @param name string
* @param type {Type}
* @param indent number
* @param overrideTypes
* @param requiredImportsFromRuntype {}
*/
function renderType(type, indent, overrideTypes, requiredImportsFromRuntype) {
if (type.isUnion()) {
// Sort the union types such that Undefined comes last always; taken from https://stackoverflow.com/a/29829370
const [first, ...rest] = type.getUnionTypes().sort((a, b) => (a.isUndefined()) - (b.isUndefined()) || +(a > b) || -(a < b));
if (!first) {
requiredImportsFromRuntype["Undefined"] = true;
return "Undefined";
}
// concatenate the types together with ".Or" to create a union type
return renderType(first, indent, overrideTypes, requiredImportsFromRuntype) + rest.map(restEl => '.Or(' + renderType(restEl, indent, overrideTypes, requiredImportsFromRuntype) + ')').join('');
}
if (type.isStringLiteral()) {
requiredImportsFromRuntype["Literal"] = true;
return `Literal(${type.getText()})`;
}
if (type.isString()) {
requiredImportsFromRuntype["String"] = true;
return "String";
}
if (type.isNumber()) {
requiredImportsFromRuntype["Number"] = true;
return "Number";
}
if (type.isAny()) {
requiredImportsFromRuntype["Unknown"] = true;
return "Unknown";
}
if (type.isUndefined()) {
requiredImportsFromRuntype["Undefined"] = true;
return "Undefined";
}
if (type.isObject()) {
// modelled after https://github.com/dsherret/ts-morph/issues/662
const isBuiltInType = type.getSymbolOrThrow().getDeclarations().some(d => d.getSourceFile().getFilePath().includes("node_modules/typescript/lib"));
if (isBuiltInType) {
requiredImportsFromRuntype["InstanceOf"] = true;
return 'InstanceOf(' + type.getText() + ')';
} else {
requiredImportsFromRuntype["Record"] = true;
return `Record({\n` + (type.getProperties().map(property => {
if (typeof overrideTypes[property.getName()] === 'string') {
return `${" ".repeat(indent + 1)}${property.getName()}: ${overrideTypes[property.getName()]}`;
}
return `${" ".repeat(indent + 1)}${property.getName()}: ${renderType(property.getValueDeclarationOrThrow().getType(), indent + 1, overrideTypes[property.getName()] || {}, requiredImportsFromRuntype)}`;
})).join(", \n") + `\n${' '.repeat(indent)}})`;
}
}
throw new Error("!!! TYPE " + type.getText() + " NOT PARSED !!!");
}
@skurfuerst
Copy link
Author

This is created as an alternative to runtypes/runtypes#128 - as I needed this in a project :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment