Skip to content

Commit

Permalink
Exposed and used TypeScript's internal isTypeAssignableTo for type co…
Browse files Browse the repository at this point in the history
…mparisons

Removes the clunky homegrown type checking logic and replaces it with the method within the type checker. The method is exposed on the tc by manually rewriting TypeScript's `tsc.js`. Very dirty.

My next commit will make it check the first few hundred chars of the file for a `/* TypeStat! */` comment so it doesn't have to re-read that whole file constantly.
  • Loading branch information
Josh Goldberg committed Feb 11, 2019
1 parent 7e554e5 commit 0129b6d
Show file tree
Hide file tree
Showing 15 changed files with 157 additions and 248 deletions.
4 changes: 4 additions & 0 deletions src/mutations/aliasing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,10 @@ export const joinIntoType = (
return undefined;
}

// Grab the built-in aliases for types we'll be outputting
const typeAliases = getApplicableTypeAliases(request);

// Convert types to their aliased names per our type aliases
let unionNames = [
...Array.from(types)
.filter(isTypeNamePrintable)
Expand All @@ -59,6 +62,7 @@ export const joinIntoType = (
...Array.from(flags).map((type) => typeAliases.get(type)!),
];

// If we exclude unmatched types, remove those
if (request.options.types.matching !== undefined) {
unionNames = filterMatchingTypeNames(unionNames, request.options.types.matching);
}
Expand Down
26 changes: 9 additions & 17 deletions src/mutations/collecting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { FileMutationsRequest } from "../mutators/fileMutator";
import { setSubtract } from "../shared/sets";
import { getApplicableTypeAliases } from "./aliasing";
import { findMissingFlags, isTypeFlagSetRecursively } from "./collecting/flags";
import { areTypesRoughlyEqual, typeIsChildOf } from "./comparisons";

/**
* Collects assigned and missing flags and types, recursively accounting for type unions.
Expand Down Expand Up @@ -108,30 +107,23 @@ const findMissingTypes = (
}
}

const rootLevelAssignedTypes = new Set(assignedTypes);
const remainingMissingTypes = new Set(assignedTypes);

const shouldRemoveAssignedType = (assignedType: ts.Type) => {
for (const potentialParentType of [...assignedTypes, ...declaredTypes]) {
if (assignedType === potentialParentType) {
continue;
}

if (
areTypesRoughlyEqual(request, assignedType, potentialParentType) ||
typeIsChildOf(request, assignedType, potentialParentType)
) {
return true;
const isAssignedTypeMissingFromDeclared = (assignedType: ts.Type) => {
for (const potentialParentType of declaredTypes) {
if (request.services.program.getTypeChecker().isTypeAssignableTo(assignedType, potentialParentType)) {
return false;
}
}

return false;
return true;
};

for (const assignedType of assignedTypes) {
if (shouldRemoveAssignedType(assignedType)) {
rootLevelAssignedTypes.delete(assignedType);
if (!isAssignedTypeMissingFromDeclared(assignedType)) {
remainingMissingTypes.delete(assignedType);
}
}

return setSubtract(rootLevelAssignedTypes, declaredTypes);
return setSubtract(remainingMissingTypes, declaredTypes);
};
165 changes: 0 additions & 165 deletions src/mutations/comparisons.ts

This file was deleted.

75 changes: 75 additions & 0 deletions src/mutations/createExposedTypeScript.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/*
This file is a ridiculous, disgusting hack and should not be considered acceptable code to write.
Shame on you for even reading this header, let alone continuing down.
TypeScript uses a function called "isTypeAssignableTo" internally that checks whether something is "assignable" to another.
For example, `5` would be considered assignable to `5`, `number`, `number | string`, `any`, and so on.
The `isTypeAssignableTo` function is not exposed externally.
https://github.com/Microsoft/TypeScript/issues/9879 tracks work to do that properly.
In the meantime, TypeStat needs to use that method, so we do the following:
1. Cry internally
2. Modify the TypeScript file given by `require.resolve` to add `isTypeAssignableTo` to created type checkers
3. Cry some more
Yes, you read that second step correct.
This file finds `typescript.js` on disk and writes a change to expose the function.
💩.
*/

import { fs } from "mz";
import { Type } from "typescript";

/* tslint:disable no-dynamic-delete no-unsafe-any no-require-imports */

type ArgumentTypes<TFunction> = TFunction extends (...args: infer TArgs) => any ? TArgs : never;

type ReplaceReturnType<TOriginalType, TReturnType> = (...args: ArgumentTypes<TOriginalType>) => TReturnType;

type Replace<TOriginalType, TReplacements extends any> = {
[Property in keyof TOriginalType]: Property extends keyof TReplacements ? TReplacements[Property] : TOriginalType[Property]
};

export type ExposedTypeScript = Replace<
typeof import("typescript"),
{
createProgram: ReplaceReturnType<(typeof import("typescript"))["createProgram"], ExposedProgram>;
}
>;

export type ExposedProgram = Replace<
import("typescript").Program,
{
getTypeChecker: ReplaceReturnType<import("typescript").Program["getTypeChecker"], ExposedTypeChecker>;
}
>;

export type ExposedTypeChecker = import("typescript").TypeChecker & {
isTypeAssignableTo(source: Type, target: Type): boolean;
};

export const requireExposedTypeScript = (): ExposedTypeScript => {
// Find where the file should be required from
const localRequireFile = require.resolve("typescript");
const originalContent = fs.readFileSync(localRequireFile).toString();

// Save and clear any existing "typescript" module from the require cache
const originalLocalRequireFile = require.cache[localRequireFile];
delete require.cache[localRequireFile];

// Write an export blurb to add `isTypeAssignableTo` to created `checker`s
const exposedContent = originalContent.replace("var checker = {", "var checker = { /* TypeStat! */ isTypeAssignableTo,");
fs.writeFileSync(localRequireFile, exposedContent);

// Require this new TypeScript that exposes `isTypeAssignableTo`
const exposedTypeScript = require(localRequireFile);

// Add back whatever existing module was cached, and reset file contents
delete require.cache[localRequireFile];
fs.writeFileSync(localRequireFile, originalContent);
require.cache[localRequireFile] = originalLocalRequireFile;

// Return the exposed version of TypeScript, and never speak of this again
return exposedTypeScript;
};
4 changes: 3 additions & 1 deletion src/services/createProgramConfiguration.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import * as fs from "mz/fs";
import * as path from "path";
import * as ts from "typescript";

import { requireExposedTypeScript } from "../mutations/createExposedTypeScript";
import { TypeStatOptions } from "../options/types";
import { collectOptionals } from "../shared/arrays";
import { normalizeAndSlashify } from "../shared/paths";

export const createProgramConfiguration = (options: TypeStatOptions) => {
const ts = requireExposedTypeScript();

// Create a TypeScript configuration using the raw options
const parsedConfiguration = ts.parseJsonConfigFileContent(
options.compilerOptions,
Expand Down
3 changes: 2 additions & 1 deletion src/services/language.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as fs from "mz/fs";
import * as ts from "typescript";

import { ExposedProgram } from "../mutations/createExposedTypeScript";
import { TypeStatOptions } from "../options/types";
import { createProgramConfiguration } from "./createProgramConfiguration";

Expand All @@ -10,7 +11,7 @@ import { createProgramConfiguration } from "./createProgramConfiguration";
export interface LanguageServices {
readonly parsedConfiguration: ts.ParsedCommandLine;
readonly languageService: ts.LanguageService;
readonly program: ts.Program;
readonly program: ExposedProgram;
}

/**
Expand Down
10 changes: 5 additions & 5 deletions test/cases/unit/variables/all/expected.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@

class SampleClassTwo implements SampleInterface {
readonly optional = false;
readonly required = 1;
readonly required = 2;
}

let onlyInterfaceImplicit = { required: 1 };
Expand All @@ -134,23 +134,23 @@
eitherClassNeedsUnionExplicit = new SampleClassTwo()!;

let eitherClassNeedsUnionExplicitInterface: SampleInterface = new SampleClassOne();
eitherClassNeedsUnionExplicit = new SampleClassTwo()!;
eitherClassNeedsUnionExplicitInterface = new SampleClassTwo()!;

let eitherClassNeedsNullImplicit = new SampleClassOne();
eitherClassNeedsNullImplicit = new SampleClassTwo()!;
eitherClassNeedsNullImplicit = null!;

let eitherClassNeedsNullAndClassExplicit: SampleClassOne | null = new SampleClassOne();
eitherClassNeedsNullAndClassExplicit = new SampleClassTwo()!;
eitherClassNeedsNullImplicit = null!;
eitherClassNeedsNullAndClassExplicit = null!;

let eitherClassNeedsUndefinedExplicit: SampleClassOne = new SampleClassOne();
eitherClassNeedsUndefinedExplicit = new SampleClassTwo()!;
eitherClassNeedsUndefinedExplicit = undefined!;

let eitherClassNeedsUndefinedExplicitInterface: SampleInterface = new SampleClassOne();
eitherClassNeedsUndefinedExplicit = new SampleClassTwo()!;
eitherClassNeedsUndefinedExplicit = undefined!;
eitherClassNeedsUndefinedExplicitInterface = new SampleClassTwo()!;
eitherClassNeedsUndefinedExplicitInterface = undefined!;

let eitherClassNeedsUndefinedAndClassExplicit: SampleClassOne | undefined = new SampleClassOne();
eitherClassNeedsUndefinedAndClassExplicit = new SampleClassTwo();
Expand Down
Loading

0 comments on commit 0129b6d

Please sign in to comment.