Skip to content

Commit 5ca3afb

Browse files
authored
chore: 12x faster TypeScript type-checking across the monorepo (#16796)
TypeScript was very slow in this repo - worst when editing files in `test/`, and for ESLint (which runs TypeScript). This PR makes it a lot faster with a few small, type-only changes. ## Why it was slow Two simple root causes: 1. **A few core field helpers and config types made TypeScript build huge piles of throwaway types** over and over. The `Field` config types are big and self-referencing, and these helpers/types kept rebuilding them. A handful of one-line type changes stop that. 2. **Every test folder loaded the entire `test/` folder instead of its own files.** Each `test/<suite>/tsconfig.json` only said `extends`, which made it pull in all ~1,600 test files (plus 87 clashing type declarations) every time you opened one test file. ## What changed (4 small fixes) 1. **Field helper functions** (`fieldAffectsData` and ~8 siblings, used in 100+ places): they narrowed a type by _combining_ it with a big list of field types, which made TypeScript build hundreds of throwaway types on every call. Changed them to _pick_ from the types that already exist instead. Same result, almost no new types created. 2. **Dashboard widgets type**: the sanitized config ran the whole (big, recursive) field type through the DeepRequired helper, rebuilding it from scratch. 3. **Import-map helper (`hasKey`)**: same "combine with a big type" problem. Removed it (callers didn't need it and it's internal-only) 4. **Test folder tsconfigs**: gave each `test/<suite>/tsconfig.json` its own file list so it only loads its own folder, not all of `test/`. ## Benchmarks ### Per package (`tsc --noEmit`, each package's own code) ``` pnpm exec tsc -b packages/payload && pnpm exec tsc -p packages/payload/tsconfig.json --noEmit --extendedDiagnostics ``` | Package | Before | After | Faster | Types before → after | | ---------------- | ------ | --------- | -------- | -------------------- | | **next** | 62.7 s | **5.1 s** | **~12×** | 186,227 → 69,716 | | **payload** | 44.0 s | **5.1 s** | **~9×** | 492,198 → 110,403 | | **ui** | 28.7 s | **7.1 s** | **~4×** | 187,494 → 104,349 | | db-mongodb | 3.2 s | 1.8 s | ~1.8× | 69,486 → 68,060 | | drizzle | 2.3 s | 1.6 s | ~1.4× | 127,890 → 126,040 | | richtext-lexical | 3.6 s | 3.3 s | ~same | 74,602 → 73,993 | | db-postgres | 0.29 s | 0.29 s | same | 24,461 → 24,461 | | translations | 0.33 s | 0.30 s | same | 51,282 → 51,282 | The big wins (payload, next, ui) are the packages that use the field types heavily. ### Whole monorepo build ``` pnpm bf ``` | | Before | After | | ------------------ | ----------------------------------------------- | ------------------ | | Full build | 2m27 | **47s** | ### Test packages | | Before | After | | ----------------------------------------------- | ---------------------------------- | --------------------------------- | | `test/fields` suite | Ran out of memory | **20.0 s** | | Single test file that imports `payload` | **76.6 s**, 4.25 GB, 489,838 types | **4.6 s**, 0.62 GB, 113,079 types | Single-file: **~17× faster, ~7× less memory, ~4× fewer types.** ### Editor latency (real `tsserver` - open a file, time until errors show) | Open this file | Before (load / errors) | After (load / errors) | | ---------------------------------------------- | ---------------------- | --------------------- | | **a `test/` file** (`test/fields/int.spec.ts`) | 13.7 s / **14.0 s** | 2.6 s / **2.5 s** | | a `payload` source file (`config/client.ts`) | 1.5 s / 3.1 s | 1.2 s / 1.4 s |
1 parent eecf87b commit 5ca3afb

87 files changed

Lines changed: 208 additions & 92 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/next/src/views/Dashboard/Default/ModularDashboard/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export async function ModularDashboard(props: DashboardViewServerProps) {
3030
const widgetSlug = layoutItem.id.slice(0, layoutItem.id.lastIndexOf('-'))
3131
const widgetConfig = widgets.find((widget) => widget.slug === widgetSlug)
3232
const widgetData = widgetConfig?.fields?.length
33-
? extractLocaleData(layoutItem.data || {}, req.locale || 'en', widgetConfig.fields as Field[])
33+
? extractLocaleData(layoutItem.data || {}, req.locale || 'en', widgetConfig.fields)
3434
: layoutItem.data || {}
3535

3636
return {

packages/next/src/views/Dashboard/Default/ModularDashboard/renderWidget/renderWidgetServerFn.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ export const renderWidgetHandler: ServerFunction<
5959

6060
try {
6161
const localeFilteredData = widgetConfig.fields?.length
62-
? extractLocaleData(widgetData || {}, req.locale || 'en', widgetConfig.fields as Field[])
62+
? extractLocaleData(widgetData || {}, req.locale || 'en', widgetConfig.fields)
6363
: widgetData || {}
6464

6565
const serverProps: WidgetServerProps = {

packages/payload/src/bin/generateImportMap/iterateFields.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@ import type { PayloadComponent, SanitizedConfig } from '../../config/types.js'
33
import type { Block, Field, Tab } from '../../fields/config/types.js'
44
import type { AddToImportMap, Imports, InternalImportMap } from './index.js'
55

6-
function hasKey<T, K extends string>(
7-
obj: null | T | undefined,
6+
// Only checks that `obj` has `key`.
7+
// Keep this narrow: adding `& T` makes TypeScript expand large field types at every call,
8+
// and callers already read components from `field` directly and don't need extra type info from this function.
9+
function hasKey<K extends string>(
10+
obj: unknown,
811
key: K,
9-
): obj is { [P in K]: PayloadComponent | PayloadComponent[] } & T {
12+
): obj is { [P in K]: PayloadComponent | PayloadComponent[] } {
1013
return obj != null && Object.prototype.hasOwnProperty.call(obj, key)
1114
}
1215

packages/payload/src/config/types.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1623,8 +1623,16 @@ export type Config = {
16231623
*/
16241624
export type SanitizedConfig = {
16251625
admin: {
1626+
/**
1627+
* `Required` (shallow) marks the top-level dashboard props as required, mainly `defaultLayout`,
1628+
* which sanitizing always fills in. Do not switch this to the `DeepRequired` used below: it
1629+
* recurses into the widgets and re-expands the whole `Field` type (a large self-referencing
1630+
* union), which is very expensive to check. Never run a `Field`-bearing type through
1631+
* `DeepRequired`.
1632+
*/
1633+
dashboard: Required<NonNullable<NonNullable<Config['admin']>['dashboard']>>
16261634
timezones: SanitizedTimezoneConfig
1627-
} & DeepRequired<Config['admin']>
1635+
} & DeepRequired<Omit<NonNullable<Config['admin']>, 'dashboard'>>
16281636
blocks?: FlattenedBlock[]
16291637
collections: SanitizedCollectionConfig[]
16301638
/** Default richtext editor to use for richText fields */

packages/payload/src/fields/config/types.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1951,7 +1951,7 @@ export type FieldWithMaxDepthClient = JoinFieldClient | RelationshipFieldClient
19511951

19521952
export function fieldHasSubFields<TField extends ClientField | Field | TabAsField>(
19531953
field: TField,
1954-
): field is TField & (TField extends ClientField ? FieldWithSubFieldsClient : FieldWithSubFields) {
1954+
): field is Extract<TField, FieldWithSubFields | FieldWithSubFieldsClient> {
19551955
return (
19561956
field.type === 'group' ||
19571957
field.type === 'array' ||
@@ -1962,19 +1962,19 @@ export function fieldHasSubFields<TField extends ClientField | Field | TabAsFiel
19621962

19631963
export function fieldIsArrayType<TField extends ClientField | Field>(
19641964
field: TField,
1965-
): field is TField & (TField extends ClientField ? ArrayFieldClient : ArrayField) {
1965+
): field is Extract<TField, ArrayField | ArrayFieldClient> {
19661966
return field.type === 'array'
19671967
}
19681968

19691969
export function fieldIsBlockType<TField extends ClientField | Field>(
19701970
field: TField,
1971-
): field is TField & (TField extends ClientField ? BlocksFieldClient : BlocksField) {
1971+
): field is Extract<TField, BlocksField | BlocksFieldClient> {
19721972
return field.type === 'blocks'
19731973
}
19741974

19751975
export function fieldIsGroupType<TField extends ClientField | Field>(
19761976
field: TField,
1977-
): field is TField & (TField extends ClientField ? GroupFieldClient : GroupField) {
1977+
): field is Extract<TField, GroupField | GroupFieldClient> {
19781978
return field.type === 'group'
19791979
}
19801980

@@ -1992,13 +1992,13 @@ export function optionIsValue(option: Option): option is string {
19921992

19931993
export function fieldSupportsMany<TField extends ClientField | Field>(
19941994
field: TField,
1995-
): field is TField & (TField extends ClientField ? FieldWithManyClient : FieldWithMany) {
1995+
): field is Extract<TField, FieldWithMany | FieldWithManyClient> {
19961996
return field.type === 'select' || field.type === 'relationship' || field.type === 'upload'
19971997
}
19981998

19991999
export function fieldHasMaxDepth<TField extends ClientField | Field>(
20002000
field: TField,
2001-
): field is TField & (TField extends ClientField ? FieldWithMaxDepthClient : FieldWithMaxDepth) {
2001+
): field is Extract<TField, FieldWithMaxDepth | FieldWithMaxDepthClient> {
20022002
return (
20032003
(field.type === 'upload' || field.type === 'relationship' || field.type === 'join') &&
20042004
typeof field.maxDepth === 'number'
@@ -2007,9 +2007,7 @@ export function fieldHasMaxDepth<TField extends ClientField | Field>(
20072007

20082008
export function fieldIsPresentationalOnly<
20092009
TField extends ClientField | Field | TabAsField | TabAsFieldClient,
2010-
>(
2011-
field: TField,
2012-
): field is TField & (TField extends ClientField | TabAsFieldClient ? UIFieldClient : UIField) {
2010+
>(field: TField): field is Extract<TField, UIField | UIFieldClient> {
20132011
return field.type === 'ui'
20142012
}
20152013

@@ -2045,8 +2043,10 @@ export function fieldAffectsData<
20452043
TField extends ClientField | Field | TabAsField | TabAsFieldClient,
20462044
>(
20472045
field: TField,
2048-
): field is TField &
2049-
(TField extends ClientField | TabAsFieldClient ? FieldAffectingDataClient : FieldAffectingData) {
2046+
// Narrows to field types that hold data (`name` fields). `Extract` keeps the
2047+
// existing `TField` members instead of creating intersections. Avoid `TField & (...)`
2048+
// here: with the large recursive `Field` union, it is much slower to typecheck.
2049+
): field is Extract<TField, FieldAffectingData | FieldAffectingDataClient> {
20502050
return 'name' in field && !fieldIsPresentationalOnly(field)
20512051
}
20522052

packages/ui/src/utilities/buildFieldSchemaMap/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ export const buildFieldSchemaMap = (args: {
8383
const matchedWidget = config.admin?.dashboard?.widgets?.find(
8484
(widget) => widget.slug === widgetSlug,
8585
)
86-
const widgetFields = matchedWidget?.fields as Field[] | undefined
86+
const widgetFields = matchedWidget?.fields
8787

8888
if (widgetFields?.length) {
8989
schemaMap.set(widgetSlug, {

test/_community/tsconfig.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
{
22
"extends": "../tsconfig.json",
3+
"include": ["./**/*.ts", "./**/*.tsx"]
34
}

test/a11y/tsconfig.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
{
2-
"extends": "../tsconfig.json"
2+
"extends": "../tsconfig.json",
3+
"include": ["./**/*.ts", "./**/*.tsx"]
34
}

test/access-control/tsconfig.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
{
2-
"extends": "../tsconfig.json"
2+
"extends": "../tsconfig.json",
3+
"include": ["./**/*.ts", "./**/*.tsx"]
34
}

test/admin/tsconfig.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
{
2-
"extends": "../tsconfig.json"
2+
"extends": "../tsconfig.json",
3+
"include": ["./**/*.ts", "./**/*.tsx"]
34
}

0 commit comments

Comments
 (0)