reissbaker/structural

View on GitHub
lib/to-ts.ts

Summary

Maintainability
D
1 day
Test Coverage
A
93%
import { Type, Comment, Either, DefaultIntersect, Validation } from "./type";
import { TypeOf } from "./checks/type-of";
import { InstanceOf } from "./checks/instance-of";
import { Value } from "./checks/value";
import { Arr } from "./checks/array";
import { PartialStruct, Struct, MissingKey, OptionalKey, Dict, MergeIntersect } from "./checks/struct";
import { MapType } from "./checks/map";
import { SetType } from "./checks/set";
import { Any } from "./checks/any";
import { Is } from "./checks/is";
import { Never } from "./checks/never";
import { Kind } from "./kind";

type ToTypescriptOpts = {
  useReference?: {
    [ref: string]: Type<any>,
  },

  indent: string,
  indentLevel: number,
};

export type TypescriptUserOpts = Partial<ToTypescriptOpts> & {
  assignToType?: string,
};

type SingleConversionWithOpts = [ type: Kind, userOpts: TypescriptUserOpts ];
type SingleConversion = [ type: Kind ];
type MultipleConversion = [ types: { [name: string]: Kind } ];

export function toTypescript(...args: SingleConversion | SingleConversionWithOpts | MultipleConversion): string {
  if(args.length === 2) {
    const [ type, userOpts ] = args;
    const opts = Object.assign({ indent: "  ", indentLevel: 0 }, userOpts);
    // assignToType is only valid at the top level, so delete it if it exists
    delete opts.assignToType;

    const ts = toTS(type, opts);

    if(userOpts.assignToType) return `type ${userOpts.assignToType} = ${ts};`;
    return ts;
  }

  const arg = args[0]
  if(arg instanceof Type) return toTypescript(arg, {});

  const keys = Object.keys(arg);
  if(keys.length === 0) return "";
  const output: string[] = [];
  for(const key of keys) {
    const refs = Object.assign({}, arg);
    delete refs[key];
    output.push(toTypescript(arg[key], {
      useReference: refs,
      assignToType: key,
    }));
  }
  return output.join("\n\n");
}

function toTS(type: Kind, opts: ToTypescriptOpts): string {
  if(opts.useReference) {
    for(const key in opts.useReference) {
      const val = opts.useReference[key];
      if(val === type) return key;
    }
  }

  if(type instanceof Comment) return fromComment(type, opts);
  if(type instanceof Either) return fromEither(type, opts);
  if(type instanceof DefaultIntersect) return fromIntersect(type, opts);
  if(type instanceof MergeIntersect) return fromIntersect(type, opts);
  if(type instanceof Validation) return fromValidation();
  if(type instanceof TypeOf) return fromTypeof(type);
  if(type instanceof InstanceOf) return fromInstanceOf(type);
  if(type instanceof Value) return fromValue(type);
  if(type instanceof Arr) return fromArr(type, opts);
  if(type instanceof Struct) return fromStruct(type, opts);
  if(type instanceof Dict) return fromDict(type, opts);
  if(type instanceof MapType) return fromMap(type, opts);
  if(type instanceof SetType) return fromSet(type, opts);
  if(type instanceof Any) return fromAny();
  if(type instanceof Is) return fromIs(type);
  if(type instanceof PartialStruct) return fromPartial(type, opts);
  return fromNever(type);
}

function fromPartial(p: PartialStruct<any>, opts: ToTypescriptOpts) {
  return `Partial<${toTS(p.struct, opts)}>`;
}

function fromComment(c: Comment<any>, opts: ToTypescriptOpts) {
  const i = indent(opts);
  const commentLines = formatCommentString(c.commentStr, opts);
  return `${commentLines}\n${i}${toTS(c.wrapped, opts)}`;
}

function formatCommentString(commentStr: string, opts: ToTypescriptOpts) {
  const i = indent(opts);

  const commentLines = commentStr.split("\n").map(line => {
    return line.trim();
  }).filter(line => line !== "");

  if(commentLines.length === 0) return "";
  if(commentLines.length === 1) {
    return `// ${commentLines[0]}`;
  }

  const lines = [ '/*' ]
  for(const line of commentLines) {
    lines.push(`${i} * ${line.trim()}`);
  }
  lines.push(`${i} */`);
  return lines.join("\n");
}

// TODO: Should either strip immediate-child comments?
function fromEither(e: Either<any, any>, opts: ToTypescriptOpts) {
  const i = indent(opts);
  return [
    toTS(e.l, opts),
    `${i}${opts.indent}| ${toTS(e.r, {...opts, indentLevel: opts.indentLevel + 1})}`,
  ].join("\n");
}

// TODO: Should intersect strip immediate-child comments?
function fromIntersect(
  i: DefaultIntersect<any, any> | MergeIntersect<any, any, any, any>,
  opts: ToTypescriptOpts,
) {
  // Handle validations chained with actual TS types, converting them to comments
  if(i.l instanceof Validation) return toTS(new Comment(i.l.desc, i.r), opts);
  if(i.r instanceof Validation) return toTS(new Comment(i.r.desc, i.l), opts);

  const indentation = indent(opts);
  return [
    toTS(i.l, opts),
    `${indentation}${opts.indent}& ${toTS(i.r, {...opts, indentLevel: opts.indentLevel + 1})}`,
  ].join("\n");
}

function fromValidation(): string {
  throw new Error(
    "Can't convert arbitrary validation functions to TypeScript types; make sure to .and() it " +
      "with a valid type, and it will be converted into a comment above the type"
  );
}

function fromTypeof(t: TypeOf<any>): string {
  switch(t.typestring) {
    case "undefined": return t.typestring;
    case "object": return "Object";
    case "boolean": return t.typestring;
    case "number": return t.typestring;
    case "bigint": return "BigInt";
    case "string": return t.typestring;
    case "symbol": return "Symbol";
    case "function": return "Function";
  }
}

function fromInstanceOf(i: InstanceOf<any>) {
  if(!i.klass.name) throw new Error("Can't convert anonymous classes to TypeScript");
  return `${i.klass.name}`;
}

function fromValue(v: Value<any>) {
  const vType = typeof v.val;
  if(vType !== "string" && vType !== "number" && vType !== "boolean" && v.val !== null && v.val !== undefined) {
    throw new Error(
      "Only string, numeric, undefined, boolean, and null value types can be auto-converted to TypeScript"
    );
  }
  if(vType === "string") return JSON.stringify(v.val);

  return `${v.val}`;
}

function fromArr(a: Arr<any>, opts: ToTypescriptOpts) {
  return `Array<${toTS(a.elementType, opts)}>`;
}

function fromStruct(s: Struct<any>, opts: ToTypescriptOpts) {
  const lines = [ "{" ];
  const keyOpts = {
    ...opts,
    indentLevel: opts.indentLevel + 1,
  };
  const keyIndent = indent(keyOpts);
  const keys = Object.keys(s.definition);

  for(let i = 0; i < keys.length; i++) {
    const key = keys[i];
    const keyType = [ key ];
    const val = s.definition[key];
    if(val instanceof MissingKey) {
      throw new Error(
        "missing(...) fields can't be represented in TypeScript; consider using optional(...)"
      );
    }
    if(val instanceof OptionalKey) keyType.push("?");
    keyType.push(": ");
    const stripped = stripOuterComments(val);
    if(stripped.comments.length > 0) {
      // Visually separate the start of a commented field unless it's the first field
      if(i !== 0) {
        // But don't double-newline if you already separated the last line
        if(lines[lines.length - 1] !== "") lines.push("");
      }
      // Put the comment on the line above the key
      lines.push(keyIndent + formatCommentString(stripped.comments.join("\n"), keyOpts));
    }
    keyType.push(toTS(stripped.inner, keyOpts));
    keyType.push(",");
    lines.push(keyIndent + keyType.join(""));

    // Visually separate the end of a commented field, unless it's the last field
    if(stripped.comments.length > 0 && i !== keys.length - 1) lines.push("");
  }
  lines.push(indent(opts) + "}");

  return lines.join("\n");
}

type StrippedComments = {
  comments: string[],
  inner: Kind,
};

function stripOuterComments(t: Kind | OptionalKey<any>): StrippedComments {
  if(t instanceof OptionalKey) return stripOuterComments(t.type);
  if(t instanceof Comment) {
    const inner = stripOuterComments(t.wrapped);
    return {
      comments: [ t.commentStr, ...inner.comments ],
      inner: inner.inner,
    }
  }

  const algebra = [ DefaultIntersect, MergeIntersect, Either ] as const;

  for(const al of algebra) {
    if(t instanceof al) {
      if(t.l instanceof Validation) {
        return stripOuterComments(new Comment(t.l.desc, t.r));
      }
      if(t.r instanceof Validation) {
        return stripOuterComments(new Comment(t.r.desc, t.l));
      }
      if(t.l instanceof Comment) {
        return stripOuterComments(handleStripAlgebra(t.l.commentStr, t.l.wrapped, t.r, al));
      }
      if(t.r instanceof Comment) {
        return stripOuterComments(handleStripAlgebra(t.r.commentStr, t.l, t.r.wrapped, al));
      }
    }
  }

  return {
    comments: [],
    inner: t,
  };
}

function handleStripAlgebra(
  desc: string,
  r: Type<any>,
  l: Type<any>,
  constr: { new(r: any, l: any): Type<any> }
): Type<any> {
  return new Comment(desc, new constr(r, l));
}

function fromDict(d: Dict<any>, opts: ToTypescriptOpts) {
  const i = indent(opts);
  const valString = toTS(d.valueType, {...opts, indentLevel: opts.indentLevel + 1});

  // For single-line values, return a single-line dict
  if(valString.indexOf("\n") < 0) return `{[${d.namedKey}: string]: ${valString}}`;

  // For multiline values, return a multiline dict
  return [
    "{",
    `${i}${opts.indent}[${d.namedKey}: string]: ${valString}`,
    `${i}}`,
  ].join("\n");
}

function fromMap(m: MapType<any, any>, opts: ToTypescriptOpts) {
  const keyString = toTS(m.keyType, opts);
  const valString = toTS(m.valueType, opts);
  return `Map<${keyString}, ${valString}>`;
}

function fromSet(s: SetType<any>, opts: ToTypescriptOpts) {
  const valString = toTS(s.valueType, opts);
  return `Set<${valString}>`;
}

function fromAny() {
  return "any";
}

function fromIs(i: Is<any>) {
  return i.name;
}

// Despite not using the type, we take it to help ensure exhaustiveness checking in the toTS fn
function fromNever(_: Never) {
  return "never";
}

function indent(opts: ToTypescriptOpts) {
  return opts.indent.repeat(opts.indentLevel);
}