Skip to content

Instantly share code, notes, and snippets.

@sliminality
Last active February 4, 2020 05:50
Show Gist options
  • Save sliminality/3ee6b87255b85c996b2f162fba10e768 to your computer and use it in GitHub Desktop.
Save sliminality/3ee6b87255b85c996b2f162fba10e768 to your computer and use it in GitHub Desktop.
Collection of useful TypeScript tricks
/**
* Script to extract dependency graph from a codebase with an entry point.
*/
import {
createCLICommand,
startIfMain,
runCommand,
createFlagMap,
t,
} from "../cli/framework"
import * as fs from "fs-extra"
import * as logger from "../shared/logger"
import * as pathLib from "path"
// import { promptToConfirm } from "../cli/utils"
import { rootPath } from "../cli/utils"
import * as ts from "typescript"
const flags = createFlagMap({
entry: {
description: "Entry point for dependency analysis",
schema: t.string(),
default: () => rootPath("src/client/main.ts"),
},
})
const command = createCLICommand({
description: "Analyze dependencies in app",
flags,
async run({ entry }) {
// Build a program.
const options = {
allowJs: true,
strictNullChecks: true,
noImplicitThis: true,
allowSyntheticDefaultImports: false,
resolveJsonModule: true,
removeComments: true,
downlevelIteration: true,
sourceMap: true,
jsx: ts.JsxEmit.React,
target: ts.ScriptTarget.ES5,
lib: ["es6", "es2017", "dom", "scripthost", "esnext.asynciterable"],
incremental: true,
outDir: "./build/",
}
const program = ts.createProgram([entry], options)
const checker = program.getTypeChecker()
// Get program source files without node_modules.
const sourceFiles = program
.getSourceFiles()
.filter(file => file.fileName.startsWith(rootPath("src")))
// Map from file to its imports.
const imports = new Map<ts.SourceFile, Set<ts.SourceFile>>()
// Map from absolute file paths to SourceFile objects.
const sourceFilesMap = new Map<string, ts.SourceFile>()
for (const file of sourceFiles) {
if (!file.isDeclarationFile) {
sourceFilesMap.set(relativePath(file.fileName), file)
imports.set(file, getImportsForFile({ file, checker }))
}
}
printDependencies(imports)
},
})
function printDependencies(imports: Map<ts.SourceFile, Set<ts.SourceFile>>) {
for (const [file, dependencies] of imports) {
const name = relativePath(file.fileName)
console.group(name)
for (const dependency of dependencies) {
if (!dependency.fileName) {
throw new Error("no dependency filename")
}
logger.log(relativePath(dependency.fileName))
}
console.groupEnd()
}
}
function getImportsForFile(args: {
file: ts.SourceFile
checker: ts.TypeChecker
}): Set<ts.SourceFile> {
const { file, checker } = args
const imports = new Set<ts.SourceFile>()
for (const importSymbol of getImports({ file, checker })) {
// Get the declaration to find out which source file it is coming from.
const declarations = importSymbol.getDeclarations()
if (!declarations) {
throw new Error(`No declarations for symbol ${importSymbol.name}`)
}
// If there are multiple declarations (.d.ts files), use the first one.
const [declaration] = declarations
const importSource = declaration.getSourceFile()
imports.add(importSource)
}
return imports
}
function getImports(args: {
file: ts.SourceFile
checker: ts.TypeChecker
}): Array<ts.Symbol> {
const { file, checker } = args
const results: Array<ts.Symbol> = []
const visitNode = (node: ts.Node) => {
if (
ts.isImportDeclaration(node) ||
ts.isImportEqualsDeclaration(node) ||
ts.isExportDeclaration(node)
) {
const moduleName = getExternalModuleName(node)
if (!moduleName) {
return
}
// Check that name is not an alias definition, e.g. `import x = y`
if (!ts.isStringLiteral(moduleName)) {
return
}
// Verify the module name can be resolved.
const moduleSymbol = checker.getSymbolAtLocation(moduleName)
if (moduleSymbol) {
results.push(moduleSymbol)
}
}
// Recur with child.
ts.forEachChild(node, visitNode)
}
ts.forEachChild(file, visitNode)
return results
}
function getExternalModuleName(node: ts.Node): ts.Expression | undefined {
if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) {
return node.moduleSpecifier
}
if (ts.isImportEqualsDeclaration(node)) {
const { moduleReference } = node
if (ts.isExternalModuleReference(moduleReference)) {
return moduleReference.expression
}
}
}
/**
* Return a path relative to the project root.
*/
function relativePath(absolute: string): string {
return pathLib.relative(rootPath(""), absolute)
}
startIfMain(module, (name, argv) => runCommand(name, command, argv))
// node.body.statements[26].declarationList.declarations[0].initializer.expression.expression.expression.expression

Diff

export type Diff<T, U> = T extends U ? never : T

Discriminated unions

Creating them is annoying. Can't figure out how to preserve key names.

// =============================================================================
// discriminate.
// =============================================================================

/**
 * Turns an object with two optional keys into a discriminated union.
 *
 * @example
 * declare var localResults: SearchResults | undefined
 * declare var serverResults: SearchResults | undefined
 * const { a: local, b: server } = utils.discriminate({ a: localResults, b: serverResults })
 */
export function discriminate<A, B, KeyA, KeyB>(
	obj: {
		a?: A
		b?: B
	},
	key1: KeyA,
	key2: KeyB
):
	| { a: A; b: B }
	| { a: A; b: undefined }
	| { a: undefined; b: B }
	| { a: undefined; b: undefined } {
	const { a, b } = obj

	// Both keys are defined.
	if (a && b) {
		return { a, b }
	}

	// Only one key is defined.
	if (a) {
		return { a, b: undefined }
	}
	if (b) {
		return { a: undefined, b }
	}

	// Neither key is defined.
	return { a: undefined, b: undefined }
}

Filtering object properties

Filters an object to the keys whose values extend the given type.

type Obj = {
    a: string,
    b: boolean,
    c: number,
    d: string,
}

type OnlyStringKeys = ObjectFilter<Obj, string>

const x: OnlyStringKeys = {
    a: "one string",
    d: "another string",
}
export type ObjectFilter<O extends object | undefined, T> = Pick<
	O,
	{
		[K in keyof O]: O[K] extends T ? K : never
	}[keyof O]
>

Predicate connectives

TypeScript's library definition of Array.prototype.filter does not propagate the result of logical operations (like ! and ||) on type predicates (like token is MentionToken).

That is, passing isMentionToken directly to filter works as expected:

declare var text: Array<TextToken>
const onlyMentions = text.filter(isMentionToken)
// refined to Array<MentionToken>

...but negating isMentionToken in a lambda does not flow the refinement through:

declare var text: Array<TextToken>
const withoutMentions = text.filter(token => !isMentionToken(token))
// still Array<TextToken>

In this case, we could do something like

function convertTextValue(text: TextValue) {
    const withoutMentions = text.filter(
        (token): token is Diff<TextToken, MentionToken> => !isMentionToken(token)
    )

but this is brittle because user-defined type guards are unchecked: if we subsequently modify the body of the predicate to add other checks, but forget to update the assertion, TypeScript won't catch it.

A similar problem arises when filtering out code annotations and mention annotations. In that case, writing

declare var annotations: Array<TextAnnotation>
const filtered = annotations.filter(
    annotation => !isCodeAnnotation(annotation) && !isMentionAnnotation(annotation)
)
// filtered is still Array<TextAnnotation>

is harder to read, and doesn't capture the refinement.

Instead, utils.filterOut allows application code to use existing predicates to subtract types from a union. Likewise, utils.isAnyOf combines predicates, propagating refinements to filter.

Filter out

export function filterOut<T, Removed extends T>(
	array: Array<T>,
	isRemoved: (item: T) => item is Removed
): Array<Diff<T, Removed>> {
	return array.filter((item): item is Diff<T, Removed> => !isRemoved(item))
}

Logical OR

export function isAnyOf<A, B, C, D>(
	pred1: (item: unknown) => item is A,
	pred2: (item: unknown) => item is B,
	pred3?: (item: unknown) => item is C,
	pred4?: (item: unknown) => item is D
) {
	return (item): item is A | B | C | D => {
		if (pred1(item) || pred2(item)) {
			return true
		}
		if (pred3 && pred3(item)) {
			return true
		}
		if (pred4 && pred4(item)) {
			return true
		}
		// ...add more cases as needed here.
		return false
	}
}
/**
* Towards a reasonable TypeScript Result<T, E> ADT.
*/
/**
* Towards a reasonable TypeScript Result<T, E> ADT.
*/
import { Assert, SuccessOrFail } from "../../shared/typeUtils"
export type Result<T, E> = (Success<T> | Fail<E>) & ResultBase<T, E>
interface ResultBase<T, E> {
error?: NonNullable<E>
map<U>(f: (value: T) => U): Result<U, E>
flatMap<U>(f: (value: T) => Result<U, E>): Result<U, E>
}
class Success<T> implements ResultBase<T, never> {
public error: undefined
constructor(public value: T) {}
public map<U>(f: (value: T) => U): Result<U, never> {
return new Success(f(this.value))
}
public flatMap<U>(f: (value: T) => Result<U, never>) {
return f(this.value)
}
public isOk(): this is Success<T> {
return true
}
}
class Fail<E> implements ResultBase<never, E> {
constructor(public error: NonNullable<E>) {}
public map<U>(f: (value: never) => U): Result<never, E> {
return this
}
public flatMap<U>(f: (value: never) => Result<U, E>) {
return this
}
public isOk(): this is Success<never> {
return false
}
}
export const Result = { Success, Fail }
/**
* Examples
*/
type Employee = { name: string; dog: string | undefined }
type EmployeeError = "Not Ivan" | "Does not own dog" | "Dog transfer failed"
declare var result: Result<Employee, EmployeeError>
export type Assert<T, V extends T> = V
if (result.error) {
type Assert1 = Assert<EmployeeError, typeof result.error>
result.value // ExpectError
} else {
type Assert1 = Assert<undefined, typeof result.error>
type Assert2 = Assert<Employee, typeof result.value>
}
// Can also be refined with `isOk`
if (result.isOk()) {
type Assert1 = Assert<undefined, typeof result.error>
type Assert2 = Assert<Employee, typeof result.value>
} else {
type Assert1 = Assert<EmployeeError, typeof result.error>
result.value // ExpectError
}
// map
const transferSimba = (e: Employee) => {
return { name: e.name, hasSimba: true } as const
}
const nextResult = result.map(transferSimba)
if (nextResult.isOk()) {
type Assert1 = Assert<undefined, typeof nextResult.error>
type Assert2 = Assert<
{ name: string; hasSimba: true },
typeof nextResult.value
>
} else {
type Assert1 = Assert<EmployeeError, typeof nextResult.error>
nextResult.value // ExpectError
}
// map
const transferSherlock = (e: Employee) => {
return Math.random() > 0.5
? new Result.Success({ name: e.name, hasSherlock: true })
: new Result.Fail("Dog transfer failed" as const)
}
const withSherlockMaybe = result.flatMap(transferSherlock)
if (withSherlockMaybe.isOk()) {
type Assert1 = Assert<undefined, typeof withSherlockMaybe.error>
type Assert2 = Assert<
{ name: string; hasSherlock: boolean },
typeof withSherlockMaybe.value
>
} else {
type Assert1 = Assert<EmployeeError, typeof withSherlockMaybe.error>
withSherlockMaybe.value // ExpectError
}

Singleton record

Given some type K <: string, construct a record containing exactly one1 field of key K and value V.

type SingletonRecord<Key extends string, V> = {[_ in Key]: V}
const x: SingletonRecord<"a", {test: number}> = {a: {test: 1}}
const y: {a: {test: number}} = x

const shouldFail: SingletonRecord<"a", {test: number}> = {
    a: {test: 1},
    // Error: Object literal may only specify known properties,
    // and 'b' does not exist in type 'SingletonRecord<"a", { test: number; }>'
    b: true,
}

[1]: "Exactly one" is, of course, something of a lie because TypeScript does not have exact object types. The idea here is to avoid ending up with an indexed type with no constraints on the keys.

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