From f100f516696d57c2c925de0225de1019aab4dbd2 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Tue, 4 Aug 2026 12:10:16 -0700 Subject: [PATCH] feat!: Upgrade to TypeScript 6 & 7 Co-Authored-By: Claude --- .github/workflows/ci.yml | 15 ++++- .gitignore | 1 + README.md | 32 +++++++++++ package-lock.json | 34 ++++++++--- package.json | 8 ++- scripts/run-tests.mjs | 113 +++++++++++++++++++++++++++++++++++++ test/errors/strictness.tsx | 32 +++++++++++ test/src/Card.tsx | 42 ++++++++++++++ test/src/legacy.js | 5 ++ test/src/utils.ts | 22 ++++++++ test/tsconfig.errors.json | 9 +++ test/tsconfig.json | 14 +++++ test/types/assets.d.ts | 12 ++++ tsconfig.json | 2 +- 14 files changed, 331 insertions(+), 10 deletions(-) create mode 100644 scripts/run-tests.mjs create mode 100644 test/errors/strictness.tsx create mode 100644 test/src/Card.tsx create mode 100644 test/src/legacy.js create mode 100644 test/src/utils.ts create mode 100644 test/tsconfig.errors.json create mode 100644 test/tsconfig.json create mode 100644 test/types/assets.d.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c6b005..c053b0d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,5 @@ name: Default CI -on: +on: push: branches: - 'master' @@ -9,6 +9,13 @@ on: jobs: tests: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # Every TypeScript major this config supports, per the `typescript` + # peerDependency range in package.json. + typescript: ['6', '7'] + name: Type check (TypeScript ${{ matrix.typescript }}) steps: - name: Checkout uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 @@ -20,3 +27,9 @@ jobs: uses: actions/setup-node@3235b876344d2a9aa001b8d1453c930bba69e610 # v3.9.1 with: node-version: ${{ env.NODE_VER }} + - name: Install dependencies + run: npm ci + - name: Install TypeScript ${{ matrix.typescript }} + run: npm install --no-save typescript@${{ matrix.typescript }} + - name: Run tests + run: npm test diff --git a/.gitignore b/.gitignore index 07e6e47..f484804 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /node_modules +/test/dist diff --git a/README.md b/README.md index 1bf7ca9..ed43242 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,12 @@ The presence of a tsconfig.json file in a directory indicates that the directory is the root of a TypeScript project. The tsconfig.json file specifies the root files and the compiler options required to compile the project (from https://www.typescriptlang.org/docs/handbook/tsconfig-json.html). +## Requirements + +This package requires TypeScript 6 or 7. Support for TypeScript 4 and 5 was dropped +because those versions do not understand `"moduleResolution": "bundler"`, which +replaces the `node10` resolution mode that TypeScript 7 removed. + ## Installation ``` @@ -33,3 +39,29 @@ Create file in repository `tsconfig.json`, with a clause `"extends": "@edx/types "exclude": ["dist", "node_modules"] } ``` + +Note that `rootDir` is no longer optional when you emit output: as of TypeScript 6, +the compiler errors with `TS5011` if it has to infer the common source directory. + +## Development + +``` +npm ci +npm test +``` + +`npm test` type-checks the fixtures in [test/](test/) against the installed TypeScript. +Those fixtures model an Open edX frontend — the `react-jsx` transform, webpack-style +SCSS imports, `paths` aliases and a plain `.js` file alongside TypeScript — so the +suite catches changes to this config that would break apps like +[frontend-app-authoring](https://github.com/openedx/frontend-app-authoring). + +`test/errors/` is a deliberately broken fixture, and the suite asserts that each +strictness option in this config reports its specific error code. Without it, a config +that quietly turned strictness off would still pass. If you change a compiler option, +update `EXPECTED_ERROR_CODES` in [scripts/run-tests.mjs](scripts/run-tests.mjs) to match. + +CI runs the suite against every supported TypeScript major. Local development installs +TypeScript 6, which is the version the Open edX frontend toolchain is currently happy +with; TypeScript 7 is supported and covered by CI, so run `npm install --no-save +typescript@7 && npm test` to check it locally. diff --git a/package-lock.json b/package-lock.json index 06b12bb..da036f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,23 +8,43 @@ "name": "@edx/typescript-config", "version": "1.0.0-semantically-released", "license": "MIT", - "devDependencies": {}, + "devDependencies": { + "@types/react": "^19.2.2", + "typescript": "^6.0.3" + }, "peerDependencies": { - "typescript": "^4.9.4" + "typescript": "^6.0.0 || ^7.0.0" } }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, "engines": { - "node": ">=4.2.0" + "node": ">=14.17" } } } diff --git a/package.json b/package.json index 5c1019d..6b7eb87 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,11 @@ "version": "1.0.0-semantically-released", "description": "TypeScript configuration for edX JavaScript code.", "main": "tsconfig.json", + "files": [ + "tsconfig.json" + ], "scripts": { + "test": "node scripts/run-tests.mjs" }, "publishConfig": { "access": "public" @@ -25,8 +29,10 @@ }, "homepage": "https://github.com/openedx/typescript-config#readme", "devDependencies": { + "@types/react": "^19.2.2", + "typescript": "^6.0.3" }, "peerDependencies": { - "typescript": "^4.9.4" + "typescript": "^6.0.0 || ^7.0.0" } } diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs new file mode 100644 index 0000000..e17f20b --- /dev/null +++ b/scripts/run-tests.mjs @@ -0,0 +1,113 @@ +#!/usr/bin/env node +// Type-checks the fixtures in test/ against whichever TypeScript version is +// installed. This package ships nothing but a tsconfig.json, so "the tests" are: +// +// 1. a realistic MFE fixture must type-check and emit cleanly, and +// 2. a deliberately broken fixture must produce the errors the config promises. +// +// Check 2 is the important one: without it, a config that silently disabled every +// strictness option would still pass check 1. + +import { spawnSync } from 'node:child_process'; +import { createRequire } from 'node:module'; +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const require = createRequire(import.meta.url); +const repoRoot = fileURLToPath(new URL('..', import.meta.url)); + +// TypeScript 7 hides `./bin/tsc` behind its `exports` map, so resolve the binary +// through package.json (which both TypeScript 6 and 7 export) instead. +const tsPackageJsonPath = require.resolve('typescript/package.json'); +const tsPackageJson = require(tsPackageJsonPath); +const tsc = resolve(dirname(tsPackageJsonPath), tsPackageJson.bin.tsc); + +// Error codes the negative fixture must trigger, one per strictness option that +// would otherwise be untested. See test/errors/strictness.tsx. +const EXPECTED_ERROR_CODES = [ + 'TS2322', // strict: wrong JSX prop type + 'TS2741', // strict: missing required prop + 'TS6133', // noUnusedParameters + 'TS18048', // strictNullChecks (via strict) + 'TS7029', // noFallthroughCasesInSwitch +]; + +let failures = 0; + +function runTsc(args) { + const result = spawnSync(process.execPath, [tsc, ...args], { + cwd: repoRoot, + encoding: 'utf8', + }); + return { status: result.status, output: `${result.stdout ?? ''}${result.stderr ?? ''}` }; +} + +function pass(name) { + console.log(` ok ${name}`); +} + +function fail(name, detail) { + failures += 1; + console.error(` FAIL ${name}`); + if (detail) { + console.error(detail.split('\n').map((line) => ` ${line}`).join('\n')); + } +} + +console.log(`typescript ${tsPackageJson.version}\n`); + +// 1. The valid fixture must type-check with no diagnostics at all. +{ + const name = 'valid fixture type-checks'; + const { status, output } = runTsc(['--noEmit', '-p', 'test/tsconfig.json']); + if (status === 0 && output.trim() === '') { + pass(name); + } else { + fail(name, output.trim() || `tsc exited with status ${status}`); + } +} + +// 2. The valid fixture must also emit: declaration + sourceMap output is part of +// what this config promises, and emit-only errors such as TS5011 (rootDir) do not +// show up under --noEmit. +{ + const name = 'valid fixture emits js, sourcemaps and declarations'; + const outDir = mkdtempSync(join(tmpdir(), 'edx-tsconfig-')); + try { + const { status, output } = runTsc(['-p', 'test/tsconfig.json', '--outDir', outDir]); + if (status !== 0 || output.trim() !== '') { + fail(name, output.trim() || `tsc exited with status ${status}`); + } else { + const missing = ['src/Card.js', 'src/Card.js.map', 'src/Card.d.ts'] + .filter((file) => !existsSync(join(outDir, file))); + if (missing.length > 0) { + fail(name, `missing expected output: ${missing.join(', ')}`); + } else { + pass(name); + } + } + } finally { + rmSync(outDir, { recursive: true, force: true }); + } +} + +// 3. The broken fixture must report every promised error. +{ + const name = 'broken fixture reports the expected errors'; + const { status, output } = runTsc(['-p', 'test/tsconfig.errors.json']); + if (status === 0) { + fail(name, 'expected type errors, but tsc succeeded'); + } else { + const missing = EXPECTED_ERROR_CODES.filter((code) => !output.includes(code)); + if (missing.length > 0) { + fail(name, `these errors were never reported: ${missing.join(', ')}\n\n${output.trim()}`); + } else { + pass(name); + } + } +} + +console.log(failures === 0 ? '\nAll checks passed.' : `\n${failures} check(s) failed.`); +process.exit(failures === 0 ? 0 : 1); diff --git a/test/errors/strictness.tsx b/test/errors/strictness.tsx new file mode 100644 index 0000000..76c19b8 --- /dev/null +++ b/test/errors/strictness.tsx @@ -0,0 +1,32 @@ +// Every line below MUST fail to compile. `scripts/run-tests.mjs` asserts that each +// listed error code is reported, which is what proves the strictness options in the +// shared config are actually in effect. Keep the codes in the script in sync. +import { Card, type CardProps } from '../src/Card'; + +// TS2322: `strict` type checking of a JSX prop. +export const wrongPropType = ; + +// TS2741: `title` is required. +export const missingProp = ; + +// TS6133: `noUnusedParameters`. +export function unusedParameter(used: string, unused: string): string { + return used; +} + +// TS18048: `strictNullChecks`, via `strict`. +export function possiblyNull(props: CardProps): number { + return props.units.length; +} + +// TS7029: `noFallthroughCasesInSwitch`. +export function fallthrough(kind: 'a' | 'b'): string { + switch (kind) { + case 'a': + const first = 'first'; + case 'b': + return 'second'; + default: + return 'none'; + } +} diff --git a/test/src/Card.tsx b/test/src/Card.tsx new file mode 100644 index 0000000..6c78f44 --- /dev/null +++ b/test/src/Card.tsx @@ -0,0 +1,42 @@ +import { useCallback, useState, type ReactElement } from 'react'; + +import styles from './Card.scss'; +import { legacyHelper } from './legacy'; +import { formatTitle, isPublished, type Unit } from '@test/utils'; + +export interface CardProps { + title: string; + units?: Unit[]; + onSelect?: (unit: Unit) => void; +} + +/** + * Exercises the `react-jsx` transform: JSX with no `React` import in scope, + * a webpack-style SCSS import, a `paths` alias and an untyped `.js` import. + */ +export function Card({ title, units = [], onSelect }: CardProps): ReactElement { + const [selected, setSelected] = useState(null); + + const handleSelect = useCallback((unit: Unit) => { + setSelected(unit); + onSelect?.(unit); + }, [onSelect]); + + return ( +
+

{formatTitle(title)}

+

{legacyHelper(selected?.id ?? 'none')}

+
    + {units.map((unit) => ( +
  • + +
  • + ))} +
+
+ ); +} + +export default Card; diff --git a/test/src/legacy.js b/test/src/legacy.js new file mode 100644 index 0000000..94f66bf --- /dev/null +++ b/test/src/legacy.js @@ -0,0 +1,5 @@ +// Plain JavaScript, type-checked only loosely: `allowJs` is on but `checkJs` is not. +// Open edX frontends still have plenty of these alongside their TypeScript. +export function legacyHelper(value) { + return String(value).toUpperCase(); +} diff --git a/test/src/utils.ts b/test/src/utils.ts new file mode 100644 index 0000000..b90d1ae --- /dev/null +++ b/test/src/utils.ts @@ -0,0 +1,22 @@ +export interface Unit { + id: string; + title: string; + publishedAt?: string; +} + +/** Exercises `satisfies`, optional chaining and nullish coalescing. */ +export function formatTitle(title: string): string { + return title.trim() satisfies string; +} + +/** Exercises ESNext lib types (`Array.prototype.at`) against an ES6 target. */ +export function firstUnit(units: Unit[]): Unit | undefined { + return units.at(0); +} + +export function isPublished(unit: Unit): boolean { + return (unit.publishedAt?.length ?? 0) > 0; +} + +// `isolatedModules` requires type-only re-exports to be marked as such. +export type { Unit as UnitType }; diff --git a/test/tsconfig.errors.json b/test/tsconfig.errors.json new file mode 100644 index 0000000..23eeaed --- /dev/null +++ b/test/tsconfig.errors.json @@ -0,0 +1,9 @@ +// The negative fixture: this project is expected to FAIL type checking. +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["errors/**/*", "src/**/*", "types/**/*"], + "exclude": ["dist", "node_modules"] +} diff --git a/test/tsconfig.json b/test/tsconfig.json new file mode 100644 index 0000000..e77f0b7 --- /dev/null +++ b/test/tsconfig.json @@ -0,0 +1,14 @@ +// Mirrors how an Open edX frontend consumes this package, e.g. +// https://github.com/openedx/frontend-app-authoring/blob/master/tsconfig.json +{ + "extends": "..", + "compilerOptions": { + "rootDir": ".", + "outDir": "dist", + "paths": { + "@test/*": ["./src/*"] + } + }, + "include": ["src/**/*", "types/**/*"], + "exclude": ["dist", "errors", "node_modules"] +} diff --git a/test/types/assets.d.ts b/test/types/assets.d.ts new file mode 100644 index 0000000..47b5cc3 --- /dev/null +++ b/test/types/assets.d.ts @@ -0,0 +1,12 @@ +// Open edX MFEs import stylesheets and images through webpack loaders. Consumers +// declare these modules themselves; the fixture does the same so that the asset +// imports below resolve the way they do in a real frontend app. +declare module '*.scss' { + const classes: Record; + export default classes; +} + +declare module '*.svg' { + const src: string; + export default src; +} diff --git a/tsconfig.json b/tsconfig.json index 6c85fc7..70f0e74 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,7 +13,7 @@ ], "isolatedModules": true, "module": "ESNext", - "moduleResolution": "node", + "moduleResolution": "bundler", "noFallthroughCasesInSwitch": true, "noImplicitAny": false, "noImplicitThis": true,