Skip to content

Instantly share code, notes, and snippets.

@giseburt
Last active December 20, 2023 14:37
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save giseburt/0a8eb7d8da91bea0dafa6e86ab86ac6e to your computer and use it in GitHub Desktop.
Save giseburt/0a8eb7d8da91bea0dafa6e86ab86ac6e to your computer and use it in GitHub Desktop.
Start A New TypeScript Project Fast! (support files)

About this file

This Gist is for the blog post Start A New TypeScript Project Fast!

This file is used to illustrate how to add a custom projen project type locally to a project.

tms-typescript-app-project.ts is copied from the TmsTypeScriptAppProject class definition at https://github.com/10mi2/tms-projen-projects/

How to use

⚠️ NOTE: Requires Node 18.18.x installed (using fnm or nvm, ideally) and active. Will probably work with Node 16 or earlier Node 18.x.x versions, but that hasn't been tested.

There is currently an issue with Node 18.19.x or newer (including Node 20) and ts-node that projen runs into with ESM enabled.

Run the following commands:

mkdir projen-test
cd projen-test
npx --yes @aws/pdk new monorepo-ts --package-manager=npm --eslint=true --prettier=true --github=true

That should build out a base monorep project.

Replace the generated .projenrc.ts file with the one from this Gist.

Download and put the tms-typescript-app-project.ts file inside ./src (you will likely need to make the src folder first).

Then run npx projen to execute the new code.

import { monorepo } from "@aws/pdk";
import { javascript } from "projen";
import { TmsTypeScriptAppProject } from "./src/tms-typescript-app-project";
const project = new monorepo.MonorepoTsProject({
devDeps: ["@aws/pdk"],
github: true,
eslint: true,
name: "projen7-tms",
packageManager: javascript.NodePackageManager.NPM,
prettier: true,
projenrcTs: true,
});
// Add the following:
new TmsTypeScriptAppProject({
parent: project,
name: "typescript-app",
defaultReleaseBranch: "main",
outdir: "app",
packageManager: project.package.packageManager,
});
// This MUST be last and only called once
project.synth();
# install node 18
fnm install 18
# or: nvm install 18
# load node a8
fnm use 18
# or: nvm use 18
# verify node 18 is in place
node --version
# v18.18.2
mkdir projen-test
cd projen-test
npx --yes @aws/pdk new monorepo-ts --package-manager=npm \
--eslint=true --prettier=true --github=true
import { monorepo } from "@aws/pdk";
import { javascript, typescript } from "projen"; // <- change this line
const project = new monorepo.MonorepoTsProject({
devDeps: ["@aws/pdk"],
github: true,
eslint: true,
name: "projen7-tms",
packageManager: javascript.NodePackageManager.NPM,
prettier: true,
projenrcTs: true,
});
// Add the following:
new typescript.TypeScriptAppProject({
parent: project,
name: "typescript-app",
defaultReleaseBranch: "main",
outdir: "app",
packageManager: project.package.packageManager,
});
// This MUST be last and only called once
project.synth();
const tsAppProject = new typescript.TypeScriptAppProject({
parent: project,
name: "typescript-app",
defaultReleaseBranch: "main",
outdir: "app",
packageManager: project.package.packageManager,
tsconfig: {
compilerOptions: {
alwaysStrict: undefined,
declaration: undefined,
esModuleInterop: undefined,
experimentalDecorators: undefined,
inlineSourceMap: undefined,
inlineSources: undefined,
lib: undefined,
module: "es2022",
noEmitOnError: undefined,
noFallthroughCasesInSwitch: undefined,
noImplicitAny: undefined,
noImplicitReturns: undefined,
noImplicitThis: undefined,
noUnusedLocals: undefined,
noUnusedParameters: undefined,
resolveJsonModule: undefined,
strict: undefined,
strictNullChecks: undefined,
strictPropertyInitialization: undefined,
stripInternal: undefined,
target: undefined,
moduleResolution: javascript.TypeScriptModuleResolution.BUNDLER,
noEmit: true,
},
extends: javascript.TypescriptConfigExtends.fromPaths([
"@tsconfig/node18/tsconfig.json",
]),
},
});
tsAppProject.addDevDeps("@tsconfig/node18");
/* eslint-disable import/no-extraneous-dependencies */
import { join as joinPath, relative as relativePath, sep } from "path";
import { Component, JsonPatch, SampleDir, TextFile } from "projen";
import {
NodePackageManager,
TypeScriptCompilerOptions,
TypeScriptModuleResolution,
TypescriptConfigExtends,
UpdateSnapshot,
} from "projen/lib/javascript";
import {
TypeScriptAppProject,
TypeScriptProject,
TypeScriptProjectOptions,
} from "projen/lib/typescript";
import { deepMerge } from "projen/lib/util";
export enum TmsTSConfigBase {
NODE_LTS = "node-lts",
NODE18 = "node18",
NODE20 = "node20",
}
const RESET_COMPILER_OPTIONS = {
alwaysStrict: undefined,
declaration: undefined,
esModuleInterop: undefined,
experimentalDecorators: undefined,
inlineSourceMap: undefined,
inlineSources: undefined,
lib: undefined,
module: undefined,
noEmitOnError: undefined,
noFallthroughCasesInSwitch: undefined,
noImplicitAny: undefined,
noImplicitReturns: undefined,
noImplicitThis: undefined,
noUnusedLocals: undefined,
noUnusedParameters: undefined,
resolveJsonModule: undefined,
strict: undefined,
strictNullChecks: undefined,
strictPropertyInitialization: undefined,
stripInternal: undefined,
target: undefined,
} satisfies Partial<TypeScriptCompilerOptions>;
export interface TmsTypeScriptAppProjectOptions
extends TypeScriptProjectOptions {
/**
* Add a default bundle to the project.
*
* Will bundle ./src/ to ./dist/ using esbuild.
*
* @default true
* @featured
*/
readonly addDefaultBundle?: boolean;
/**
* Configure for ESM
*
* @default true
* @featured
*/
readonly esmSupportConfig?: boolean;
/**
* Change the default-set eslint auto-fixable rules to "warn" instead of "error"
*
* @default true
* @featured
*/
readonly eslintFixableAsWarn?: boolean;
/**
* Declare a specific node version to put in `.nvmrc` for `nvm` or `fnm` to use
*
* NOTE: As of this writing ts-node (v10.9.1) has issues with node versions 18.19.x and newer (including 20.x)
* when esm is enabled (`esmSupportConfig: true`)
*
* @default v18.18.2
* @featured
*/
readonly nodeVersion?: string;
/**
* TSConfig base configuration selection
*
* Using one of the options from https://github.com/tsconfig/bases as a base, then any explicit settings override
* those. Note that only nodes18 and above are supported.
*
* @remarks
*
* Not all options are supported, perticularly those of node version that are older than is supported by Projen.
*
* @see {@link tsconfigBaseStrictest}
*
* @default TmsTSConfigBase.Node18
*
*/
readonly tsconfigBase?: TmsTSConfigBase;
/**
* TSConfig base configuration selection for `tsconfig.dev.json`, used to run projen itslef via `ts-node`
*
* @remarks
*
* Due to a bug in `ts-node` (v10.9.1) when used with Typescript 5.3.2 the tsconfig "extends" property when referring
* to a file that's in `node_modules` will not be looked up properly. The workaround currently is to make the
* reference relative to the tsconfig file.
*
* @see {@link tsconfigBase}
*
* @default TmsTSConfigBase.Node18
*
*/
readonly tsconfigBaseDev?: TmsTSConfigBase;
/**
* Include TSConfig "strinctest" configuration to {@link tsconfigBase}
*
* Using one of the options from https://github.com/tsconfig/bases as a base, then any explicit settings override
* those. Note that only nodes18 and above are supported.
*
* @remarks
*
* If true will add the "strictest" configuration to the "extends" of the tsconfig.json file, *before* the base set by
* the {@link tsconfigBase} option.
*
* Does **not** apply to `tsconfig.dev.json`.
*
* @see {@link tsconfigBase}
*
* @default true
*
*/
readonly tsconfigBaseStrictest?: boolean;
}
/**
* Create a [TypeScriptAppProject](https://projen.io/api/API.html#typescriptappproject-) with
* [Ten Mile Square](https://tenmilesquare.com) opinionated defaults.
*
* @pjid tms-typescript-app
*/
export class TmsTypeScriptAppProject extends TypeScriptAppProject {
constructor(options: TmsTypeScriptAppProjectOptions) {
const defaultOptions = {
eslint: true,
packageManager: NodePackageManager.NPM,
prettier: true,
projenrcTs: true,
nodeVersion: "v18.18.2",
vscode: true,
tsconfig: {
compilerOptions: {
...RESET_COMPILER_OPTIONS,
moduleResolution: TypeScriptModuleResolution.NODE16,
noEmit: options.addDefaultBundle ?? true,
},
extends: TypescriptConfigExtends.fromPaths([
...(options.tsconfigBaseStrictest ?? true
? ["@tsconfig/strictest/tsconfig.json"]
: []),
`@tsconfig/${
options.tsconfigBase ?? TmsTSConfigBase.NODE18
}/tsconfig.json`,
]),
},
tsconfigDev: {
compilerOptions: {
...RESET_COMPILER_OPTIONS,
moduleResolution: TypeScriptModuleResolution.NODE16,
noEmit: true,
},
extends: TypescriptConfigExtends.fromPaths([
`${relativePath(
joinPath(options.outdir ?? "."),
"./node_modules",
).replace(/^(?!\.)/, "./")}/@tsconfig/${
options.tsconfigBaseDev ?? TmsTSConfigBase.NODE18
}/tsconfig.json`,
]),
},
jest: true,
jestOptions: {
updateSnapshot: UpdateSnapshot.NEVER,
},
addDefaultBundle: true,
esmSupportConfig: true,
tsconfigBase: TmsTSConfigBase.NODE18,
tsconfigBaseDev: TmsTSConfigBase.NODE18,
tsconfigBaseStrictest: true,
} satisfies Partial<TmsTypeScriptAppProjectOptions>;
const mergedOptions = deepMerge(
[
defaultOptions, // will get mutated
options,
],
true,
) as TmsTypeScriptAppProjectOptions;
super({ ...mergedOptions, sampleCode: false });
this.addDevDeps(
...new Set([
`@tsconfig/${mergedOptions.tsconfigBase ?? TmsTSConfigBase.NODE18}`,
`@tsconfig/${mergedOptions.tsconfigBaseDev ?? TmsTSConfigBase.NODE18}`,
...(mergedOptions.tsconfigBaseStrictest ?? true
? ["@tsconfig/strictest"]
: []),
]),
);
// Note: should adjust for https://eslint.style/guide/getting-started
if (mergedOptions.eslintFixableAsWarn ?? true) {
this.eslint?.addRules({
"prettier/prettier": ["warn"],
"import/order": [
"warn",
{
groups: ["builtin", "external"],
alphabetize: {
order: "asc",
caseInsensitive: true,
},
},
],
"key-spacing": ["warn"],
"no-multiple-empty-lines": ["warn"],
"no-trailing-spaces": ["warn"],
"dot-notation": ["warn"],
});
}
if (mergedOptions.nodeVersion) {
new TextFile(this, ".nvmrc", {
lines: [mergedOptions.nodeVersion],
});
}
if (mergedOptions.esmSupportConfig) {
this.package.addField("type", "module");
[this.tsconfig, this.tsconfigDev].forEach(
(tsconfig) =>
tsconfig &&
tsconfig.file.patch(
JsonPatch.add("/ts-node", {
esm: true,
preferTsExts: true,
experimentalSpecifierResolution: "node",
}),
),
);
} else {
[this.tsconfig, this.tsconfigDev].forEach(
(tsconfig) =>
tsconfig &&
tsconfig.file.patch(
JsonPatch.add("/ts-node", {
compilerOptions: {
module: "commonjs",
},
preferTsExts: true,
experimentalSpecifierResolution: "node",
}),
),
);
}
if (mergedOptions.jest && this.jest) {
this.jest.config.globals = undefined;
this.jest.config.transform = {
"^.+\\.[tj]sx?$": [
"ts-jest",
{
useESM: mergedOptions.esmSupportConfig ?? true,
tsconfig: "tsconfig.dev.json",
},
],
};
this.jest.config.moduleNameMapper = {
"^(\\.{1,2}/.*)\\.js$": "$1",
};
if (mergedOptions.esmSupportConfig ?? true) {
this.jest.config.preset = "ts-jest/presets/default-esm";
this.testTask.env("NODE_OPTIONS", "--experimental-vm-modules");
} else {
this.jest.config.preset = "ts-jest/presets/default";
}
}
if (mergedOptions.vscode) {
if (!this.vscode?.settings) {
throw new Error("vscode settings not found, but should have been");
}
if (!this.vscode?.extensions) {
throw new Error("vscode extensions not found, but should have been");
}
const settings = this.vscode?.settings;
settings.addSetting("jest.jestCommandLine", "npm test --");
settings.addSetting("jest.rootPath", "./");
const extensions = this.vscode?.extensions;
extensions.addRecommendations(
"dbaeumer.vscode-eslint",
"Orta.vscode-jest",
);
}
if (mergedOptions.addDefaultBundle ?? true) {
this.bundler.addBundle([this.srcdir, "index.ts"].join(sep), {
target: "node18",
platform: "node",
format: mergedOptions.esmSupportConfig ?? true ? "esm" : "cjs",
sourcemap: true,
});
}
if (mergedOptions.sampleCode ?? true) {
new SampleCode(this);
}
}
}
class SampleCode extends Component {
constructor(project: TypeScriptProject) {
super(project);
const indexSrcCode = [
'import { Hello } from "./hello.js";',
"",
"console.log(await new Hello().sayHello(2000));",
].join("\n");
const helloSrcCode = [
'import { setTimeout } from "timers/promises";',
"export class Hello {",
" public async sayHello(delay: number = 100): Promise<string> {",
" await setTimeout(delay);",
' return "hello, world!";',
" }",
"}",
].join("\n");
const testCode = [
'import { Hello } from "../src/hello.js";',
"",
'test("hello", async () => {',
" const hello = new Hello();",
' expect(await hello.sayHello()).toBe("hello, world!");',
"});",
].join("\n");
new SampleDir(project, project.srcdir, {
files: {
"index.ts": indexSrcCode,
"hello.ts": helloSrcCode,
},
});
if (project.jest) {
new SampleDir(project, project.testdir, {
files: {
"hello.test.ts": testCode,
},
});
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment