TS: Casting to Interfaces at Runtime

TS: Casting to Interfaces at Runtime

In C#/Unity, there is an excellent affordance of using GetComponent to retrieve components based on their interface. That is, thing.GetComponent<IClickable>() will return the component which matches the IClickable interface, if one exists.

TypeScript, while offering interfaces, can NOT offer the same runtime checking. Through the use of codegen, we can create a set of functions which allow us to mimic this runtime interface checking. This approach relies on ‘duck typing’ by ensuring a given object satisfies all of the requirements of a given interface.

This is particularly useful in situations when you are trying to discern what functionality a given object has.


Usage

Running the codegen is triggered through a simple console command - any of these will work:

yarn codegen
npm run codegen
ts-node codegen.ts

By default, this will find all interfaces with the I prefix (IClickable etc) and generate the necessary casting functions in a new file. Here’s an example input file and generated output:

Input

// my-interface.ts
export interface IClickable {
  onClick(): void;
}
export interface IRightClickable extends IClickable {
  onRightClick(): void;
}

Output

// my-interface.gen.ts

// This is an autogenerated file, DO NOT EDIT.
// This file was generated from `ButtonComponent.ts` by running `npm run gen:components`.
import type { IClickable, IRightClickable } from './my-interface';

export function CastToClickable(obj: any): IClickable | null {
  return (obj !== null && obj !== undefined && typeof(obj.onClick) === "function") ? obj : null;
}

export function CastToRightClickable(obj: any): IRightClickable | null {
  return (obj !== null && obj !== undefined && CastToClickable(obj) !== null && typeof(obj.onRightClick) === "function") ? obj : null;
}

Notice here that the CastToRightClickable function actually makes a call to CastToClickable. This is because the interface extends IClickable, so therefor we check against its properties as well. This behavior can be changed through config settings to instead use an ’expanded’ form of checks which do not rely on other Cast calls. Here’s an example of such output:

export function CastToClickable(obj: any): IClickable | null {
  return (obj !== null && obj !== undefined && typeof(obj.onClick) === "function") ? obj : null;
}
export function CastToRightClickable(obj: any): IRightClickable | null {
  return (obj !== null && obj !== undefined && typeof(obj.onClick) === "function" && typeof(obj.onRightClick) === "function") ? obj : null;
}

Usage from here is simply calling the relevant cast function:


function onEvent(target:any) {
  const clickable = CastToClickable(target);
  if (clickable) {
    clickable.onClick();
  }

  // a one-liner can be written using the ? operator:
  CastToRightClickable(target)?.onRightClick();
}

Implementation (codegen.ts)

  1. Add this file to your project in the same directory as your tsconfig.json
  2. npm install -D ts-node ts-morph (or yarn add ts-node ts-morph -D.
  3. Optional: In your package.json, add a new script: "codegen": "ts-node ./codegen.ts"
  4. Optional: There are a handful of settings at the top of codegen.ts, revise as necessary
import fs from "fs";
import path from "path";
import {
  InterfaceDeclaration,
  Project,
  PropertySignature,
  SourceFile,
  Type,
  ts,
} from "ts-morph";

// Simple object containing a bunch of util functions.
const Utils = {
  nullRegex: /\s*\|\s*null\s*|\s*null\s*\|\s*/gi,
  checkTypeNullable: (type: Type<ts.Type>, prop: PropertySignature) =>
    type.isNull() ||
    type.isNullable() ||
    Utils.nullRegex.test(prop.getTypeNodeOrThrow().getText()),

  // Returns the header prefixed to every file that is created by this script
  getGenfileHeader(file: SourceFile) {
    return `// This is an autogenerated file, DO NOT EDIT.
// This file was generated from \`${file.getBaseName()}\` by running \`npm run gen:components\`.\n`;
  }
}

// CONFIG SETTINGS
const TSCONFIG_PATH = path.resolve(__dirname, "./tsconfig.json");
const GEN_FILE_EXT = ".gen.ts";
const FUNC_PREFIX = "CastTo";
const PREFER_REUSE_CAST_FUNCTIONS = true; // `true` can cause circular dependencies
const REQUIRE_I_PREFIX = false;


// Load the source files listed in the tsconfig
const project = new Project();
project.addSourceFilesFromTsConfig(TSCONFIG_PATH);

// Process each file and output the relevant file
project.getSourceFiles().forEach(generateCodegenFile);

function generateCodegenFile(sourceFile: SourceFile) {
  // If this function is called on a previously-generated file, ignore it
  if (sourceFile.getBaseName().endsWith(GEN_FILE_EXT)) {
    return;
  }

  const outputFilePath = path.resolve(
    sourceFile.getDirectoryPath(),
    `./${sourceFile.getBaseNameWithoutExtension()}${GEN_FILE_EXT}`
  );

  // If this file exists already, just straight up remove it.
  // It may be outdated and no longer necessary, so this will prune the file.
  // If it IS needed, then it'll simply be regenerated.
  if (fs.existsSync(outputFilePath)) {
    fs.unlinkSync(outputFilePath);
  }

  const interfaces = sourceFile.getInterfaces();

  // If there are no interfaces in this file at all, then we don't need to look at it further
  if (interfaces.length == 0) {
    return;
  }
  console.log("Processing " + sourceFile.getBaseName() + "...");

  let imports = new Map<SourceFile, Set<string>>();
  let generatedCode = "";
  let hasOutput = false;

  // for each interface found in this source file...
  interfaces.forEach((int) => {
    const interfaceName = int.getName();
    var compiledPropChecks = processInterface(int, imports);

    if (compiledPropChecks.length === 0) {
      return;
    }
    hasOutput = true;

    // Since the cast functions accept 'any' as a param, we need to double check the possibility
    // that the input is null/undefined
    compiledPropChecks.unshift("obj !== null && obj !== undefined");

    generatedCode += `
export function ${FUNC_PREFIX}${interfaceName.slice(1)}(obj: any): ${interfaceName} | null {
  return (${compiledPropChecks.join(" && ")}) ? obj : null;
}
`;
  });

  if (!hasOutput) {
    return;
  }

  let importString = "";
  const items = imports.entries();

  let value: [SourceFile, Set<string>];
  while ((value = items.next()?.value)) {
    const [file, members] = value;
    importString += `import type { ${Array.from(members.values()).join(", ")} } from '${file.getRelativePathAsModuleSpecifierTo(sourceFile.getDirectoryPath())}/${file.getBaseNameWithoutExtension()}';\n`;
  }

  generatedCode = Utils.getGenfileHeader(sourceFile) + importString + generatedCode;

  fs.writeFileSync(outputFilePath, generatedCode);
}

function processInterface(
  interfaceDeclaration: InterfaceDeclaration,
  importsRef: Map<SourceFile, Set<string>>,
  isInherited: boolean = false
): string[] {
  const propertiesCheckCode: string[] = [];
  const interfaceName = interfaceDeclaration.getName();

  if (REQUIRE_I_PREFIX && interfaceName.charAt(0) !== "I") {
    console.log(`Skipping interface "${interfaceName}"`);
    return propertiesCheckCode;
  }


  //
  const file = interfaceDeclaration.getSourceFile();
  const list = importsRef.get(file) ?? new Set<string>();
  list.add(interfaceName);
  importsRef.set(file, list);

  // For interfaces with extensions, we will simply confirm that we can cast to the ancestors

  // IF TRYING TO REUSE EXISTING CAST FUNCTIONS...
  if (PREFER_REUSE_CAST_FUNCTIONS) {
    interfaceDeclaration.getBaseDeclarations().forEach((i) => {
      propertiesCheckCode.push(
        `${FUNC_PREFIX}${i.getName()!.slice(1)}(obj) !== null`
      );
    });
  } else {
    interfaceDeclaration.getBaseDeclarations().forEach((i) => {
      const subProps = processInterface(
        <InterfaceDeclaration>i,
        importsRef,
        true
      );
      propertiesCheckCode.push(...subProps);
    });
  }

  // Ensure all methods exist. (Unfortunately we can't check if the return types are compatible!)
  interfaceDeclaration.getMethods().forEach((meth) => {
    const propName = meth.getName();
    propertiesCheckCode.push(`typeof(obj.${propName}) === "function"`);
  });

  // Check the fields for the given interface, confirming the types are correct
  interfaceDeclaration.getProperties().forEach((prop) => {
    const type = prop.getType();
    if (prop.hasQuestionToken()) {
      return;
    }

    const isNullable = Utils.checkTypeNullable(type, prop);

    const propName = prop.getName();
    if (isNullable) {
      propertiesCheckCode.push(
        `(typeof(obj.${propName}) === "${type.getText()}" || obj.${propName} === null)`
      );
    } else {
      propertiesCheckCode.push(
        `typeof(obj.${propName}) === "${type.getText()}"`
      );
    }
  });

  if (!isInherited) {
    console.log(`\tGenerated ${interfaceName} methods...`);
  }

  return propertiesCheckCode;
}

Related Posts

Z-Order Curve

Z-Order Curve

Nemo vel ad consectetur namut rutrum ex, venenatis sollicitudin urna. Aliquam erat volutpat.

Read More
Bitmasker

Bitmasker

Small helper class to track ‘flagged’ state. Works together with an enum and provides easy toggled/not toggled checks.

Read More
TypeScript + Lua

TypeScript + Lua

Nemo vel ad consectetur namut rutrum ex, venenatis sollicitudin urna. Aliquam erat volutpat.

Read More