Skip to content

Instantly share code, notes, and snippets.

@0xdevalias
Created January 21, 2025 09:28
Show Gist options
  • Save 0xdevalias/17c6daae6dd03d81487d6de8d6282ad5 to your computer and use it in GitHub Desktop.
Save 0xdevalias/17c6daae6dd03d81487d6de8d6282ad5 to your computer and use it in GitHub Desktop.
PoC script playing around with programmatically viewing pretty-printed TypeScript types, for deeply nested/complex types
#!/usr/bin/env node
// Ref: https://ts-morph.com/navigation/example
// https://ts-morph.com/emitting
import { Project } from "ts-morph";
import path from "path";
import fs from "fs";
// Function to parse command-line arguments
const parseArgs = () => {
const args = process.argv.slice(2); // Skip 'node' and script name
const options = {};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--file" || arg === "-f") {
options.file = args[i + 1];
i++;
} else if (arg === "--identifier" || arg === "-i") {
options.identifier = args[i + 1];
i++;
}
}
return options;
};
// Parse CLI arguments
const { file, identifier } = parseArgs();
if (!file || !identifier) {
console.error("Usage: emit-types.js --file <file> --identifier <identifier>");
console.error(
"Example: emit-types.js --file src/file.ts --identifier MyType",
);
process.exit(1);
}
// Resolve file path and check existence
const filePath = path.resolve(file);
if (!fs.existsSync(filePath)) {
console.error(`Error: File '${filePath}' does not exist.`);
process.exit(1);
}
// Initialize ts-morph Project
const project = new Project({
tsConfigFilePath: "./tsconfig.json",
compilerOptions: {
outDir: "dist",
noEmit: false,
declaration: true,
},
});
project.addSourceFilesAtPaths("**/*.ts");
// Function to get the type of a specific identifier
// const getTypeOfIdentifier = (file, identifier) => {
// const variableDeclaration = file.getVariableDeclaration(identifier);
// const functionDeclaration = file.getFunctionDeclaration(identifier);
//
// let type;
//
// if (variableDeclaration) {
// const typeChecker = project.getTypeChecker();
// type = typeChecker.getTypeAtLocation(variableDeclaration);
// } else if (functionDeclaration) {
// const typeChecker = project.getTypeChecker();
// type = typeChecker.getTypeAtLocation(functionDeclaration);
// } else {
// throw new Error(
// `Identifier '${identifier}' not found in file '${file.getFilePath()}'`,
// );
// }
//
// return type?.getText() || "Unable to resolve type.";
// };
// Function to resolve and pretty-print a type alias
// const prettyPrintTypeAlias = (file, aliasName) => {
// const typeAlias = file.getTypeAlias(aliasName);
// if (!typeAlias) {
// throw new Error(
// `Type alias '${aliasName}' not found in file '${file.getFilePath()}'`,
// );
// }
//
// const typeChecker = project.getTypeChecker();
// const type = typeAlias.getType();
// const typeText = typeChecker.getTypeText(type);
//
// // If you need to expand references, iterate through them
// if (type.isObject()) {
// const properties = type.getProperties();
// console.log(`Type '${aliasName}' properties:`);
// for (const prop of properties) {
// console.log(
// ` ${prop.getName()}: ${typeChecker.getTypeText(prop.getTypeAtLocation(typeAlias))}`,
// );
// }
// }
//
// return typeText;
// };
// /**
// * Check if a property is relevant for pretty-printing.
// * @param {string} name - The property name.
// * @returns {boolean} - Whether the property is relevant.
// */
// const isRelevantProperty = (name) => {
// // Ignore internal Zod properties and methods
// return (
// !name.startsWith("_") && !["parse", "safeParse", "describe"].includes(name)
// );
// };
/**
* Check if a type is a Zod type.
* @param {string} typeText - The type text.
* @returns {boolean} - Whether the type is a Zod type.
*/
const isZodType = (typeText) => typeText.startsWith("Zod.");
/**
* Recursively inspect the declarations of a type.
* @param {import("ts-morph").Type} type - The type to inspect.
* @param {Set<string | undefined>} visitedTypes - The set of visited types.
* @param {number} [depth=0] - The current depth of recursion.
* @returns {object[] | string} - The inspected declarations.
*/
const inspectDeclarations = (type, visitedTypes = new Set(), depth = 0) => {
const indent = " ".repeat(depth);
const typeChecker = project.getTypeChecker();
// Circular reference detection
const typeName = type.getSymbol()?.getName();
if (visitedTypes.has(typeName)) {
return "[Circular Reference]";
}
if (typeName) {
visitedTypes.add(typeName);
}
if (type.isObject()) {
const properties = type.getProperties();
const results = properties.map((prop) => {
// Get the value declaration of the property
const valueDeclaration = prop.getValueDeclaration();
const resolvedType = valueDeclaration?.getType(); // Resolved type of the property
// Get all declarations for this property
const declarations = prop.getDeclarations().map((declaration) => {
const declarationType = declaration.getType(); // Resolved type of the declaration
return {
declarationText: declaration.getText(), // Source-level text
compiledTypeText: declarationType.getText(), // Resolved type as inferred by TypeScript
};
});
// Check if all declarations resolve to Zod types
const allDeclarationsAreZod = declarations.every((decl) =>
isZodType(decl.compiledTypeText),
);
// Use the parent's resolved type if all declarations map to Zod types
const childInspection = allDeclarationsAreZod
? (resolvedType?.getText() ?? "unknown") // Use the parent's text directly
: depth < MAX_DEPTH && resolvedType?.isObject()
? inspectDeclarations(resolvedType, visitedTypes, depth + 1)
: (resolvedType?.getText() ?? "unknown");
return {
"type.getArrayElementType()?.getText()":
type.getArrayElementType()?.getText() ?? null,
"type.getTargetType()?.getText()":
type.getTargetType()?.getText() ?? null,
propertyName: prop.getName(),
"prop.getValueDeclaration()?.getType().getText()":
valueDeclaration?.getType().getText() ?? null,
"prop.getDeclaredType().getText()": prop.getDeclaredType().getText(),
declarations,
resolvedType: childInspection,
};
});
return results;
}
return typeChecker.getTypeText(type);
};
// /**
// * Inspect the declarations of a type.
// * @param {import("ts-morph").Type} type - The type to inspect.
// * @returns {object[]} - The declarations of the type.
// */
// const inspectDeclarations = (type) => {
// const properties = type.getProperties();
//
// const results = properties.map((prop) => {
// const declarations = prop.getDeclarations().map((declaration) => {
// const declarationType = declaration.getType(); // Resolved type of the declaration
// declarationType.get;
//
// return {
// declarationText: declaration.getText(), // Source-level text
// compiledTypeText: declarationType.getText(), // Resolved type as inferred by TypeScript
// isObject: declarationType.isObject(),
// isArray: declarationType.isArray(),
// };
// });
//
// return {
// propertyName: prop.getName(),
// declarations,
// };
// });
//
// return results;
// };
// Configurable maximum depth for recursion
const MAX_DEPTH = 5; // Limit depth to avoid deep expansion
/**
* Recursively pretty-print a type.
* @param {import("ts-morph").Type} type - The type to pretty-print.
* @param visitedTypes
* @param {number} [depth=0] - The current depth of recursion.
* @returns {string} - The pretty-printed type.
*/
const prettyPrintType = (type, visitedTypes = new Set(), depth = 0) => {
const indent = " ".repeat(depth);
const typeChecker = project.getTypeChecker();
// Base case for primitive types and literals
if (type.isString()) return "string";
if (type.isNumber()) return "number";
if (type.isBoolean()) return "boolean";
if (type.isUndefined()) return "undefined";
if (type.isNull()) return "null";
if (type.isAny()) return "any";
if (type.isUnknown()) return "unknown";
if (type.isNever()) return "never";
if (type.isVoid()) return "void";
if (type.isLiteral()) return JSON.stringify(type.getLiteralValue());
// Circular reference detection
// const typeName = type.getSymbol()?.getName();
// if (visitedTypes.has(typeName)) {
// return "[Circular Reference]";
// }
//
// if (typeName) {
// visitedTypes.add(typeName);
// }
if (type.isUnion()) {
const unionTypes = type.getUnionTypes();
return unionTypes
.map((u) => prettyPrintType(u, visitedTypes, depth))
.join(" | ");
// return typeChecker.getTypeText(type);
}
if (type.isIntersection()) {
const intersectionTypes = type.getIntersectionTypes();
return intersectionTypes
.map((i) => prettyPrintType(i, visitedTypes, depth))
.join(" & ");
// return typeChecker.getTypeText(type);
}
if (type.getText().startsWith("Zod.")) {
return type.getText();
}
if (type.isArray()) {
const elementType = type.getArrayElementType();
return `Array<${prettyPrintType(elementType, visitedTypes, depth)}>`;
// return typeChecker.getTypeText(type);
}
if (type.isObject()) {
console.log(
`DEBUG-inspectDeclarations[depth=${depth}]:\n`,
`type.getText(): ${type.getText()}`,
`type.getDefault().getText(): ${type.getDefault()?.getText()}`,
JSON.stringify(inspectDeclarations(type), null, 2),
);
const properties = type.getProperties();
const lines = properties.map((prop) => {
// prop.getDeclarations().map((declaration) => declaration.getType().getText());
const declarationTypes = prop
.getDeclarations()
.map((declaration) => declaration.getType());
// console.log(
// "Declaration:\n",
// " declaration.getText()\n ",
// declaration.getText(),
// "\n",
// " declaration.getFullText()\n ",
// declaration.getFullText(),
// "\n",
// " declaration.getType().getText()\n ",
// declaration.getType().getText(),
// "\n",
// " declaration.getType().getApparentType().getText(),\n ",
// declaration.getType().getApparentType().getText(),
// "\n",
// " declaration.getType().getTargetType()?.getText(),\n ",
// declaration.getType().getTargetType()?.getText(),
// "\n",
// " declaration.getType().getArrayElementType()?.getText(),,\n ",
// declaration.getType().getArrayElementType()?.getText(),
// );
//
// return declaration.getType();
//
// // return declaration.getType().getText();
// });
//
// return `${indent} ${prop.getName()}: ${declarationStrings.join("AAAA ")}`;
// const propValueType = prop.getDeclarations()?.[0]?.getType();
const propName = prop.getName();
// const propValue = prop.getValueDeclaration();
// const propValueType = propValue?.getType();
const propValueType = declarationTypes[0];
// console.log(
// indent,
// `DEBUG[depth=${depth}]:\n`,
// " propName\n ",
// propName,
// "\n",
// " declarationTypes.map((dt) => dt.getText())\n ",
// declarationTypes.map((dt) => dt.getText()),
// "\n",
// " prop.getDeclaredType().getText()\n ",
// prop.getDeclaredType().getText(), // note: This seems to resolve to 'any' a lot of the time
// "\n",
// " propValueType.isObject()\n ",
// propValueType?.isObject(),
// "\n",
// " propValueType.isArray()\n ",
// propValueType?.isArray(),
// "\n",
// " propValueType.isNumber()\n ",
// propValueType?.isNumber(),
// "\n",
// " propValueType.isString()\n ",
// propValueType?.isString(),
// "\n",
// " propValueType.getText()\n ",
// propValueType?.getText(), // note: This seems to resolve to Zod.ZodString, etc.
// "\n",
// // " propValueType?.getProperties().map name->valueDeclaration.type.text\n ",
// " propValueType?.getProperties().map\n ",
// propValueType?.getProperties().map(
// (prop) => [
// { name: prop.getName() },
// { getDeclaredType: prop.getDeclaredType().getText() },
// {
// getValueDeclaration: prop
// .getValueDeclaration()
// ?.getType()
// .getText(),
// },
// prop.getDeclarations().map((d) => d.getType().getText()),
// ],
// // `${prop.getName()}: ${prop.getValueDeclaration()?.getType().getText()}`,
// ),
// "\n",
// );
if (
propValueType?.isObject() &&
// !propValueType?.isArray() &&
depth < MAX_DEPTH
) {
// const innerTypes = propValueType
// ?.getProperties()
// .map((p) => p.getValueDeclaration()?.getType())
// .filter((t) => t !== undefined)
// .map((t) => prettyPrintType(t, visitedTypes, depth + 1));
return `${indent} ${prop.getName()}: ${prettyPrintType(propValueType, visitedTypes, depth + 1)}`;
} else {
return `${indent} ${prop.getName()}: ${propValueType?.getText()}`;
}
});
return `{\n${lines.join(",\n")}\n${indent}}`;
}
// Return basic text type representation of the type
const typeText = typeChecker.getTypeText(type);
return typeText;
};
/**
* Resolve and pretty-print a type alias.
* @param {import("ts-morph").SourceFile} file - The source file containing the type alias.
* @param {string} aliasName - The name of the type alias.
* @returns {string} - The pretty-printed type alias.
*/
const prettyPrintTypeAlias = (file, aliasName) => {
const typeAlias = file.getTypeAlias(aliasName);
if (!typeAlias) {
throw new Error(
`Type alias '${aliasName}' not found in file '${file.getFilePath()}'`,
);
}
const type = typeAlias.getType();
return prettyPrintType(type);
};
// Extract and print the type
try {
// Add the file to the project
const sourceFile = project.getSourceFileOrThrow(filePath);
const emitOutput = sourceFile.getEmitOutput({ emitOnlyDtsFiles: true });
console.log(
"skipped:",
emitOutput.getEmitSkipped(),
"emitted count",
emitOutput.compilerObject.outputFiles.length,
);
if (emitOutput.getEmitSkipped()) {
console.error("Emit was skipped for:", sourceFile.getFilePath());
console.log(emitOutput.getDiagnostics().map((d) => d.getMessageText()));
} else {
console.log("File emitted successfully.");
for (const file of emitOutput.getOutputFiles()) {
console.log("----");
console.log(file.getFilePath());
console.log("----");
console.log(file.getText());
console.log("\n");
}
}
console.log("----");
console.log("File:", sourceFile.getFilePath(), "\n");
console.log(
"Interfaces:",
sourceFile.getInterfaces().map((iface) => iface.getName()),
"\n",
);
console.log(
"TypeAliases:",
sourceFile.getTypeAliases().map((typeAlias) => typeAlias.getName()),
"\n",
);
const prettyType = prettyPrintTypeAlias(sourceFile, identifier);
console.log(`Type alias '${identifier}':\n\n${prettyType}`);
// const emitResult = sourceFile.emitSync();
} catch (error) {
console.error("Error:", error.message, error);
process.exit(1);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment