Repository: danvk/literate-ts (ts)

Issue:
Decide what to do with `keyof` expressions
I have some code samples like this in _Effective TypeScript_:

```ts
interface Options {
  width: number;
  height: number;
  color: string;
  label: string;
}

type OptionsKeys = keyof Options;
// Type is "width" | "height" | "color" | "label"
```

This stopped working as of [TS 4.2] when the type started displaying as `type OptionsKey = keyof Options`. I [don't like this][1], think it's [a bug][3] and have [written about it][2] but it seems like this change is here to stay. literate-ts needs some way to handle it.

One idea:

```ts
type OptionsKeys = keyof Options;
//   ^? type OptionsKey = keyof Options (equivalent to "width" | "height" | "color" | "label")
```

The "equivalent to" could trigger wrapping in a `Nop` / `Resolve` type.

[TS 4.2]: https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-2.html#smarter-type-alias-preservation
[1]: https://twitter.com/danvdk/status/1414343269522227207
[2]: https://effectivetypescript.com/2022/02/25/gentips-4-display/
[3]: https://github.com/microsoft/TypeScript/issues/42322

Pull request:
Add special "type equivalent to" assertions
Fixes #132 

Instead of:

```ts
interface Point {
  x: number;
  y: number;
}

type T = keyof Point;
//   ^? type T = keyof Point
```

You can now write:

```ts
interface Point {
  x: number;
  y: number;
}

type T = keyof Point;
//   ^? type T = keyof Point (equivalent to "x" | "y")
```

and literate-ts will quietly insert some [machinery](https://effectivetypescript.com/2022/02/25/gentips-4-display/) to resolve the type and check that, too.

Patch:
diff --git a/README.md b/README.md
index 1cb359b..c86c321 100644
--- a/README.md
+++ b/README.md
@@ -301,6 +301,29 @@ be careful not to mislead the reader when you do this.
 
 If you'd like to keep your source files self-contained, you can put the replacement in a code block and reference it with a `replace-with-id` directive. See [this sample file](/examples/asciidoc/failures.asciidoc) for examples.
 
+## Equivalent types
+
+Sometimes TypeScript's type display can be a bit opaque, particularly with `keyof` expressions:
+
+```ts
+interface Point {
+  x: number;
+  y: number;
+}
+
+type T = keyof Point;
+//   ^? type T = keyof Point
+```
+
+While correct and faithful to what you see in Quick Info, this isn't very useful. You can add an "equivalent to" clause to the type assertion to get some more resolution on the type (and clarify what it is for your readers):
+
+```ts
+type T = keyof Point;
+//   ^? type T = keyof Point (equivalent to "x" | "y")
+```
+
+When it sees this sort of assertion, literate-ts will quietly insert some [machinery] to resolve the type and check both the raw and resolved types.
+
 ## Command-Line Options
 
 - `--help`: Show help.
@@ -404,3 +427,4 @@ Publish a new version:
 [etsblog]: https://effectivetypescript.com/
 [pylit-post]: https://www.onebigfluke.com/2014/07/how-im-writing-programming-book.html
 [twoslash]: https://shikijs.github.io/twoslash/
+[machinery]: https://effectivetypescript.com/2022/02/25/gentips-4-display/
diff --git a/src/code-sample.ts b/src/code-sample.ts
index a205363..bd43717 100644
--- a/src/code-sample.ts
+++ b/src/code-sample.ts
@@ -267,6 +267,38 @@ export function applyReplacements(
   return samples;
 }
 
+const EQUIVALENT_RE = /\^\? type ([A-Za-z0-9_]+) = (.*)( \(equivalent to (.*)\))$/m;
+const EQUIVALENT_MULTILINE_RE =
+  /\^\? type ([A-Za-z0-9_]+) = (.*)(\n\s*\/\/ +\(equivalent to (.*)\))$/m;
+
+/** Patch the code sample to test "equivalent to" types */
+export function addResolvedChecks(sample: CodeSample): CodeSample {
+  const {content} = sample;
+  if (!content.includes('equivalent to')) {
+    return sample;
+  }
+
+  const m = EQUIVALENT_RE.exec(content) || EQUIVALENT_MULTILINE_RE.exec(content);
+  if (!m) {
+    return sample;
+  }
+
+  const [, typeName, _raw, equivClause, equivType] = m;
+
+  // Strip the "equivalent to" bit, add Resolve<T> helper and secondary type assertion.
+  // See https://github.com/danvk/literate-ts/issues/132 and
+  // https://effectivetypescript.com/2022/02/25/gentips-4-display/
+  let newContent = content.replace(equivClause, '');
+  newContent += '\ntype Resolve<Raw> = Raw extends Function ? Raw : {[K in keyof Raw]: Raw[K]};';
+  newContent += `\ntype Synth${typeName} = Resolve<${typeName}>;`;
+  newContent += `\n//   ^? type Synth${typeName} = ${equivType}\n`;
+
+  return {
+    ...sample,
+    content: newContent,
+  };
+}
+
 export function extractSamples(text: string, slug: string, sourceFile: string) {
   let processor;
   if (sourceFile.endsWith('.asciidoc')) {
diff --git a/src/processor.ts b/src/processor.ts
index c9d55c0..da4e5c4 100644
--- a/src/processor.ts
+++ b/src/processor.ts
@@ -1,7 +1,12 @@
 import fs from 'fs';
 import {dirname, isAbsolute} from 'path';
 
-import {applyPrefixes, extractSamples, applyReplacements} from './code-sample.js';
+import {
+  applyPrefixes,
+  extractSamples,
+  applyReplacements,
+  addResolvedChecks,
+} from './code-sample.js';
 import {fileSlug, writeTempFile} from './utils.js';
 import {log} from './logger.js';
 import {startFile, fail, finishFile, finishSample, startSample} from './test-tracker.js';
@@ -77,7 +82,7 @@ export class Processor {
     log(`Found ${rawSamples.length} code samples in ${path}`);
 
     const replacedSamples = applyReplacements(rawSamples, this.sources);
-    const samples = applyPrefixes(replacedSamples);
+    const samples = applyPrefixes(replacedSamples).map(addResolvedChecks);
 
     const outputs = _.keyBy(samples, 'id');
     for (const [i, sample] of Object.entries(samples)) {
diff --git a/src/ts-checker.ts b/src/ts-checker.ts
index 1a52094..04374b3 100644
--- a/src/ts-checker.ts
+++ b/src/ts-checker.ts
@@ -555,16 +555,7 @@ export async function checkTs(
   return result;
 }
 
-/** Verify that a TypeScript sample has the expected errors and no others. */
-async function uncachedCheckTs(
-  sample: CodeSample,
-  runCode: boolean,
-  config: ConfigBundle,
-): Promise<CheckTsResult> {
-  const {id, content} = sample;
-  const fileName = id + (sample.isTSX ? '.tsx' : `.${sample.language}`);
-  const tsFile = writeTempFile(fileName, content);
-  const sampleDir = getTempDir();
+function setupNodeModules(sample: CodeSample, sampleDir: string, options: ts.CompilerOptions) {
   const nodeModulesPath = path.join(sampleDir, 'node_modules');
   fs.emptyDirSync(nodeModulesPath);
 
@@ -578,12 +569,7 @@ async function uncachedCheckTs(
     }
     // Use the TypeScript API to resolve a location for this module relative to the source file.
     // Copy it over and add any transitive dependencies to the list.
-    const {resolvedModule} = ts.resolveModuleName(
-      nodeModule,
-      sourceFileAbsPath,
-      config.options,
-      ts.sys,
-    );
+    const {resolvedModule} = ts.resolveModuleName(nodeModule, sourceFileAbsPath, options, ts.sys);
     if (!resolvedModule) {
       fail(
         `Could not find requested (or transitive) node_module ${nodeModule}. See logs for details.`,
@@ -607,11 +593,25 @@ async function uncachedCheckTs(
       allModules.push(dep); // no need to de-dupe here, it's done at the top of the loop.
     }
   }
+}
+
+/** Verify that a TypeScript sample has the expected errors and no others. */
+async function uncachedCheckTs(
+  sample: CodeSample,
+  runCode: boolean,
+  config: ConfigBundle,
+): Promise<CheckTsResult> {
+  const {id, content} = sample;
+  const fileName = id + (sample.isTSX ? '.tsx' : `.${sample.language}`);
+  const tsFile = writeTempFile(fileName, content);
+  const sampleDir = getTempDir();
 
   const options: ts.CompilerOptions = {
     ...config.options,
     ...sample.tsOptions,
   };
+
+  setupNodeModules(sample, sampleDir, options);
   if (!_.isEmpty(sample.nodeModules)) {
     options.typeRoots = [path.join(sampleDir, 'node_modules', '@types')];
   }


Test patch:
diff --git a/src/test-tracker.ts b/src/test-tracker.ts
index 72b71ca..ac76a9a 100644
--- a/src/test-tracker.ts
+++ b/src/test-tracker.ts
@@ -23,7 +23,7 @@ export function startSample(sample: CodeSample) {
   currentSample = sample;
   results[currentFile][sample.descriptor] = 0;
   sampleStartMs = Date.now();
-  log(`BEGIN #${sample.descriptor} (id for --filter: ${sample.id})\n`);
+  log(`BEGIN #${sample.descriptor} (--filter ${sample.id})\n`);
 }
 
 export function finishSample() {
diff --git a/src/test/__snapshots__/asciidoc.test.ts.snap b/src/test/__snapshots__/asciidoc.test.ts.snap
index 11f773f..63a9231 100644
--- a/src/test/__snapshots__/asciidoc.test.ts.snap
+++ b/src/test/__snapshots__/asciidoc.test.ts.snap
@@ -6,7 +6,7 @@ exports[`checker asciidoc checker snapshots "./src/test/inputs/commented-sample-
     "---- BEGIN FILE ./src/test/inputs/commented-sample-with-error.asciidoc
 ",
     "Found 2 code samples in ./src/test/inputs/commented-sample-with-error.asciidoc",
-    "BEGIN #././src/test/inputs/commented-sample-with-error.asciidoc:7 (id for --filter: commented-sample-with-error-7)
+    "BEGIN #././src/test/inputs/commented-sample-with-error.asciidoc:7 (--filter commented-sample-with-error-7)
 ",
     "💥 ./src/test/inputs/commented-sample-with-error.asciidoc:7:7-12: Unexpected TypeScript error: Type 'string' is not assignable to type 'number'.",
     "const value: number = "123";",
@@ -14,7 +14,7 @@ exports[`checker asciidoc checker snapshots "./src/test/inputs/commented-sample-
     "
 END #././src/test/inputs/commented-sample-with-error.asciidoc:7 (--- ms)
 ",
-    "BEGIN #././src/test/inputs/commented-sample-with-error.asciidoc:17 (id for --filter: commented-sample-with-error-17)
+    "BEGIN #././src/test/inputs/commented-sample-with-error.asciidoc:17 (--filter commented-sample-with-error-17)
 ",
     "💥 ././src/test/inputs/commented-sample-with-error.asciidoc:17: Unexpected TypeScript error: Type 'string' is not assignable to type 'number'.",
     "const value: number = "123";
@@ -35,13 +35,118 @@ END #././src/test/inputs/commented-sample-with-error.asciidoc:17 (--- ms)
 }
 `;
 
+exports[`checker asciidoc checker snapshots "./src/test/inputs/equivalent.asciidoc": ./src/test/inputs/equivalent.asciidoc 1`] = `
+{
+  "logs": [
+    "---- BEGIN FILE ./src/test/inputs/equivalent.asciidoc
+",
+    "Found 4 code samples in ./src/test/inputs/equivalent.asciidoc",
+    "BEGIN #././src/test/inputs/equivalent.asciidoc:8 (--filter equivalent-8)
+",
+    "Code passed type checker.",
+    "Twoslash type assertion match:",
+    "  Expected: type T = keyof Point",
+    "    Actual: type T = keyof Point",
+    "  1/1 twoslash type assertions matched.",
+    "
+END #././src/test/inputs/equivalent.asciidoc:8 (--- ms)
+",
+    "BEGIN #././src/test/inputs/equivalent.asciidoc:23 (--filter equivalent-23)
+",
+    "Code passed type checker.",
+    "Twoslash type assertion match:",
+    "  Expected: type T = keyof Point",
+    "    Actual: type T = keyof Point",
+    "Twoslash type assertion match:",
+    "  Expected: type T2 = keyof Point",
+    "    Actual: type T2 = keyof Point",
+    "Twoslash type assertion match:",
+    "  Expected: type SynthT2 = "x" | "y"",
+    "    Actual: type SynthT2 = "x" | "y"",
+    "  3/3 twoslash type assertions matched.",
+    "
+END #././src/test/inputs/equivalent.asciidoc:23 (--- ms)
+",
+    "BEGIN #././src/test/inputs/equivalent.asciidoc:31 (--filter equivalent-31)
+",
+    "Code passed type checker.",
+    "Twoslash type assertion match:",
+    "  Expected: type T = keyof Point",
+    "    Actual: type T = keyof Point",
+    "Twoslash type assertion match:",
+    "  Expected: type T2 = keyof Point",
+    "    Actual: type T2 = keyof Point",
+    "💥 ./src/test/inputs/equivalent.asciidoc:34:6-13: Failed type assertion for \`SynthT2\`
+  Expected: type SynthT2 = "x" | "y" | "z"
+    Actual: type SynthT2 = "x" | "y"",
+    "  2/3 twoslash type assertions matched.",
+    "interface Point {
+  x: number;
+  y: number;
+}
+
+type T = keyof Point;
+//   ^? type T = keyof Point
+type T2 = keyof Point;
+//   ^? type T2 = keyof Point
+type Resolve<Raw> = Raw extends Function ? Raw : {[K in keyof Raw]: Raw[K]};
+type SynthT2 = Resolve<T2>;
+//   ^? type SynthT2 = "x" | "y" | "z"
+",
+    "tsconfig options: {"strictNullChecks":true,"module":1}",
+    "
+END #././src/test/inputs/equivalent.asciidoc:31 (--- ms)
+",
+    "BEGIN #././src/test/inputs/equivalent.asciidoc:39 (--filter equivalent-39)
+",
+    "Code passed type checker.",
+    "Twoslash type assertion match:",
+    "  Expected: type T = keyof Point",
+    "    Actual: type T = keyof Point",
+    "Twoslash type assertion match:",
+    "  Expected: type T2 = keyof Point",
+    "    Actual: type T2 = keyof Point",
+    "💥 ./src/test/inputs/equivalent.asciidoc:42:6-13: Failed type assertion for \`SynthT2\`
+  Expected: type SynthT2 = "x" | "y" | "z"
+    Actual: type SynthT2 = "x" | "y"",
+    "  2/3 twoslash type assertions matched.",
+    "interface Point {
+  x: number;
+  y: number;
+}
+
+type T = keyof Point;
+//   ^? type T = keyof Point
+type T2 = keyof Point;
+//   ^? type T2 = keyof Point
+type Resolve<Raw> = Raw extends Function ? Raw : {[K in keyof Raw]: Raw[K]};
+type SynthT2 = Resolve<T2>;
+//   ^? type SynthT2 = "x" | "y" | "z"
+",
+    "tsconfig options: {"strictNullChecks":true,"module":1}",
+    "
+END #././src/test/inputs/equivalent.asciidoc:39 (--- ms)
+",
+    "---- END FILE ./src/test/inputs/equivalent.asciidoc
+",
+  ],
+  "statuses": [
+    "1/1: ././src/test/inputs/equivalent.asciidoc",
+    "1/1: ././src/test/inputs/equivalent.asciidoc: 1/4 ././src/test/inputs/equivalent.asciidoc:8",
+    "1/1: ././src/test/inputs/equivalent.asciidoc: 2/4 ././src/test/inputs/equivalent.asciidoc:23",
+    "1/1: ././src/test/inputs/equivalent.asciidoc: 3/4 ././src/test/inputs/equivalent.asciidoc:31",
+    "1/1: ././src/test/inputs/equivalent.asciidoc: 4/4 ././src/test/inputs/equivalent.asciidoc:39",
+  ],
+}
+`;
+
 exports[`checker asciidoc checker snapshots "./src/test/inputs/president.asciidoc": ./src/test/inputs/president.asciidoc 1`] = `
 {
   "logs": [
     "---- BEGIN FILE ./src/test/inputs/president.asciidoc
 ",
     "Found 2 code samples in ./src/test/inputs/president.asciidoc",
-    "BEGIN #././src/test/inputs/president.asciidoc:7 (id for --filter: president-7)
+    "BEGIN #././src/test/inputs/president.asciidoc:7 (--filter president-7)
 ",
     "Code passed type checker.",
     "💥 ./src/test/inputs/president.asciidoc:9:7-16: Failed type assertion for \`president\`
@@ -64,7 +169,7 @@ const president = {...firstLast, ...(hasMiddle ? {middle: 'S'} : {})};
     "
 END #././src/test/inputs/president.asciidoc:7 (--- ms)
 ",
-    "BEGIN #././src/test/inputs/president.asciidoc:21 (id for --filter: president-21)
+    "BEGIN #././src/test/inputs/president.asciidoc:21 (--filter president-21)
 ",
     "Code passed type checker.",
     "💥 ./src/test/inputs/president.asciidoc:23:7-16: Failed type assertion for \`const president = {...firstLast, ...(hasMiddle ? {middle: 'S'} : {})};\` (tested \`president\`)
@@ -261,6 +366,97 @@ exports[`extractSamples snapshot: doc1 1`] = `
 ]
 `;
 
+exports[`extractSamples snapshot: equivalent 1`] = `
+[
+  {
+    "checkJS": false,
+    "content": "interface Point {
+  x: number;
+  y: number;
+}
+
+type T = keyof Point;
+//   ^? type T = keyof Point",
+    "descriptor": "./equivalent.asciidoc:8",
+    "id": "equivalent-8",
+    "isTSX": false,
+    "language": "ts",
+    "lineNumber": 7,
+    "nodeModules": [],
+    "prefixes": [],
+    "prefixesLength": 0,
+    "replacementId": undefined,
+    "sectionHeader": null,
+    "sourceFile": "equivalent.asciidoc",
+    "tsOptions": {},
+  },
+  {
+    "checkJS": false,
+    "content": "type T2 = keyof Point;
+//   ^? type T2 = keyof Point (equivalent to "x" | "y")",
+    "descriptor": "./equivalent.asciidoc:23",
+    "id": "equivalent-23",
+    "isTSX": false,
+    "language": "ts",
+    "lineNumber": 22,
+    "nodeModules": [],
+    "prefixes": [
+      {
+        "id": "equivalent-8",
+      },
+    ],
+    "prefixesLength": 0,
+    "replacementId": undefined,
+    "sectionHeader": null,
+    "sourceFile": "equivalent.asciidoc",
+    "tsOptions": {},
+  },
+  {
+    "checkJS": false,
+    "content": "type T2 = keyof Point;
+//   ^? type T2 = keyof Point (equivalent to "x" | "y" | "z")",
+    "descriptor": "./equivalent.asciidoc:31",
+    "id": "equivalent-31",
+    "isTSX": false,
+    "language": "ts",
+    "lineNumber": 30,
+    "nodeModules": [],
+    "prefixes": [
+      {
+        "id": "equivalent-8",
+      },
+    ],
+    "prefixesLength": 0,
+    "replacementId": undefined,
+    "sectionHeader": null,
+    "sourceFile": "equivalent.asciidoc",
+    "tsOptions": {},
+  },
+  {
+    "checkJS": false,
+    "content": "type T2 = keyof Point;
+//   ^? type T2 = keyof Point
+//      (equivalent to "x" | "y" | "z")",
+    "descriptor": "./equivalent.asciidoc:39",
+    "id": "equivalent-39",
+    "isTSX": false,
+    "language": "ts",
+    "lineNumber": 38,
+    "nodeModules": [],
+    "prefixes": [
+      {
+        "id": "equivalent-8",
+      },
+    ],
+    "prefixesLength": 0,
+    "replacementId": undefined,
+    "sectionHeader": null,
+    "sourceFile": "equivalent.asciidoc",
+    "tsOptions": {},
+  },
+]
+`;
+
 exports[`extractSamples snapshot: inline-replacement 1`] = `
 [
   {
diff --git a/src/test/asciidoc.test.ts b/src/test/asciidoc.test.ts
index 9d7f30e..055501f 100644
--- a/src/test/asciidoc.test.ts
+++ b/src/test/asciidoc.test.ts
@@ -448,6 +448,7 @@ describe('checker', () => {
   test.each([
     './src/test/inputs/commented-sample-with-error.asciidoc',
     './src/test/inputs/president.asciidoc',
+    './src/test/inputs/equivalent.asciidoc',
   ])(
     'asciidoc checker snapshots %p',
     async inputFile => {
diff --git a/src/test/code-sample.test.ts b/src/test/code-sample.test.ts
index 8d0b861..bece97f 100644
--- a/src/test/code-sample.test.ts
+++ b/src/test/code-sample.test.ts
@@ -1,4 +1,10 @@
-import {stripSource, applyPrefixes, extractSamples, applyReplacements} from '../code-sample.js';
+import {
+  stripSource,
+  applyPrefixes,
+  extractSamples,
+  applyReplacements,
+  addResolvedChecks,
+} from '../code-sample.js';
 import {PrefixedCodeSample} from '../types.js';
 import {dedent} from '../utils.js';
 import {baseExtract, baseSample} from './common.js';
@@ -407,3 +413,146 @@ describe('applyReplacements', () => {
     ]);
   });
 });
+
+describe('addResolvedChecks', () => {
+  it('should leave most code samples alone', () => {
+    const sample = applyPrefixes(
+      extractSamples(
+        dedent`
+          [source,ts]
+          ----
+          interface Point {
+            x: number;
+            y: number;
+          }
+          type T = keyof Point;
+          //   ^? type T = keyof Point
+          ----
+          `,
+        'equivalent-assertion',
+        'source.asciidoc',
+      ),
+    );
+    expect(addResolvedChecks(sample[0])).toEqual(sample[0]);
+  });
+
+  it('should patch a code sample with an "equivalent to" assertion', () => {
+    const sample = applyPrefixes(
+      extractSamples(
+        dedent`
+          [source,ts]
+          ----
+          interface Point {
+            x: number;
+            y: number;
+          }
+          type T = keyof Point;
+          //   ^? type T = keyof Point (equivalent to "x" | "y")
+          ----
+          `,
+        'equivalent-assertion',
+        'source.asciidoc',
+      ),
+    );
+    expect(addResolvedChecks(sample[0]).content).toEqual(
+      dedent`
+      interface Point {
+        x: number;
+        y: number;
+      }
+      type T = keyof Point;
+      //   ^? type T = keyof Point
+      type Resolve<Raw> = Raw extends Function ? Raw : {[K in keyof Raw]: Raw[K]};
+      type SynthT = Resolve<T>;
+      //   ^? type SynthT = "x" | "y"
+      `,
+    );
+  });
+
+  it('should patch a type assertion in a larger code sample', () => {
+    const sample = applyPrefixes(
+      extractSamples(
+        dedent`
+          This is also a helpful mindset with finite sets, such as the ones you might get from
+          \`keyof T\`, which returns type for just the keys of an object type:
+
+          // verifier:reset
+          [[sort-by]]
+          [source,ts]
+          ----
+          interface Point {
+            x: number;
+            y: number;
+          }
+          type PointKeys = keyof Point;
+          //   ^? type PointKeys = keyof Point (equivalent to "x" | "y")
+
+          function sortBy<K extends keyof T, T>(vals: T[], key: K): T[] {
+            // ...
+          }
+          const pts: Point[] = [{x: 1, y: 1}, {x: 2, y: 0}];
+          sortBy(pts, 'x');  // OK, 'x' extends 'x'|'y' (aka keyof T)
+          sortBy(pts, 'y');  // OK, 'y' extends 'x'|'y'
+          sortBy(pts, Math.random() < 0.5 ? 'x' : 'y');  // OK, 'x'|'y' extends 'x'|'y'
+          sortBy(pts, 'z');
+                   // ~~~ Type '"z"' is not assignable to parameter of type '"x" | "y"
+          ----
+
+          The set interpretation also makes more sense when you have types whose relationship
+          isn't strictly hierarchical.
+        `,
+        'equivalent-assertion',
+        'source.asciidoc',
+      ),
+    );
+
+    expect(addResolvedChecks(sample[0]).content).toEqual(dedent`
+      interface Point {
+        x: number;
+        y: number;
+      }
+      type PointKeys = keyof Point;
+      //   ^? type PointKeys = keyof Point
+
+      function sortBy<K extends keyof T, T>(vals: T[], key: K): T[] {
+        // ...
+      }
+      const pts: Point[] = [{x: 1, y: 1}, {x: 2, y: 0}];
+      sortBy(pts, 'x');  // OK, 'x' extends 'x'|'y' (aka keyof T)
+      sortBy(pts, 'y');  // OK, 'y' extends 'x'|'y'
+      sortBy(pts, Math.random() < 0.5 ? 'x' : 'y');  // OK, 'x'|'y' extends 'x'|'y'
+      sortBy(pts, 'z');
+               // ~~~ Type '"z"' is not assignable to parameter of type '"x" | "y"
+      type Resolve<Raw> = Raw extends Function ? Raw : {[K in keyof Raw]: Raw[K]};
+      type SynthPointKeys = Resolve<PointKeys>;
+      //   ^? type SynthPointKeys = "x" | "y"
+      `);
+  });
+
+  it('should patch a type assertion split across lines', () => {
+    const sample = applyPrefixes(
+      extractSamples(
+        dedent`
+          You can also split the assertion onto another line:
+
+          [source,ts]
+          ----
+          type T2 = keyof Point;
+          //   ^? type T2 = keyof Point
+          //      (equivalent to "x" | "y")
+          ----
+        `,
+        'equivalent-assertion',
+        'source.asciidoc',
+      ),
+    );
+
+    expect(addResolvedChecks(sample[0]).content).toEqual(dedent`
+      type T2 = keyof Point;
+      //   ^? type T2 = keyof Point
+      type Resolve<Raw> = Raw extends Function ? Raw : {[K in keyof Raw]: Raw[K]};
+      type SynthT2 = Resolve<T2>;
+      //   ^? type SynthT2 = "x" | "y"
+      `);
+  });
+});
diff --git a/src/test/inputs/equivalent.asciidoc b/src/test/inputs/equivalent.asciidoc
new file mode 100644
index 0000000..0827efd
--- /dev/null
+++ b/src/test/inputs/equivalent.asciidoc
@@ -0,0 +1,42 @@
+See https://github.com/danvk/literate-ts/issues/132 for context.
+
+As of TS 4.2, `keyof` expressions resolve in an opaque and not very useful way:
+
+// verifier:prepend-to-following
+[source,ts]
+----
+interface Point {
+  x: number;
+  y: number;
+}
+
+type T = keyof Point;
+//   ^? type T = keyof Point
+----
+
+You can use some https://effectivetypescript.com/2022/02/25/gentips-4-display/[machinery] to inline the type, but this usually distracts from the point of the text.
+
+There's a special "equivalent to" syntax that quietly inserts some helper code to resolve the type behind the scenes:
+
+[source,ts]
+----
+type T2 = keyof Point;
+//   ^? type T2 = keyof Point (equivalent to "x" | "y")
+----
+
+Errors should be reported though probably not in a relevant location:
+
+[source,ts]
+----
+type T2 = keyof Point;
+//   ^? type T2 = keyof Point (equivalent to "x" | "y" | "z")
+----
+
+You can also split the assertion onto another line:
+
+[source,ts]
+----
+type T2 = keyof Point;
+//   ^? type T2 = keyof Point
+//      (equivalent to "x" | "y" | "z")
+----