Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: Default CI
on:
on:
push:
branches:
- 'master'
Expand All @@ -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
Expand All @@ -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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
/node_modules
/test/dist
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand All @@ -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.
34 changes: 27 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
}
}
113 changes: 113 additions & 0 deletions scripts/run-tests.mjs
Original file line number Diff line number Diff line change
@@ -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);
32 changes: 32 additions & 0 deletions test/errors/strictness.tsx
Original file line number Diff line number Diff line change
@@ -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 = <Card title={42} />;

// TS2741: `title` is required.
export const missingProp = <Card />;

// 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';
}
}
42 changes: 42 additions & 0 deletions test/src/Card.tsx
Original file line number Diff line number Diff line change
@@ -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<Unit | null>(null);

const handleSelect = useCallback((unit: Unit) => {
setSelected(unit);
onSelect?.(unit);
}, [onSelect]);

return (
<div className={styles.card}>
<h2>{formatTitle(title)}</h2>
<p>{legacyHelper(selected?.id ?? 'none')}</p>
<ul>
{units.map((unit) => (
<li key={unit.id}>
<button type="button" onClick={() => handleSelect(unit)}>
{unit.title} — {isPublished(unit) ? 'published' : 'draft'}
</button>
</li>
))}
</ul>
</div>
);
}

export default Card;
5 changes: 5 additions & 0 deletions test/src/legacy.js
Original file line number Diff line number Diff line change
@@ -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();
}
22 changes: 22 additions & 0 deletions test/src/utils.ts
Original file line number Diff line number Diff line change
@@ -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 };
9 changes: 9 additions & 0 deletions test/tsconfig.errors.json
Original file line number Diff line number Diff line change
@@ -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"]
}
14 changes: 14 additions & 0 deletions test/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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"]
}
Loading
Loading