-
-
Notifications
You must be signed in to change notification settings - Fork 32.7k
[blog] A pattern for opt-in type-only breaking changes in minor versions #47622
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
7371d61
b04d51b
541df8d
bb72f73
db75099
fd72d59
b657467
b711a23
3510f90
387a042
3f14346
5d79282
ad5eb8f
f4a2b32
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| import TopLayoutBlog from 'docs/src/modules/components/TopLayoutBlog'; | ||
| import { docs } from './pattern-type-only-breaking-changes-minor-versions.md?muiMarkdown'; | ||
|
|
||
| export default function Page() { | ||
| return <TopLayoutBlog docs={docs} />; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,213 @@ | ||
| --- | ||
| title: A pattern for opt-in type-only breaking changes in minor versions | ||
| description: Discover how MUI prevents type-breaking changes in minor versions using TypeScript's interface merging and module augmentation. | ||
|
Check warning on line 3 in docs/pages/blog/pattern-type-only-breaking-changes-minor-versions.md
|
||
| date: 2026-01-15 | ||
| authors: ['bernardobelchior'] | ||
| tags: ['MUI X', 'Tech'] | ||
| manualCard: false | ||
| --- | ||
|
|
||
| At MUI, type errors across non-major versions are considered breaking changes. This article demonstrates how we use TypeScript's interface merging and module augmentation features to allow users to opt in to breaking changes in types, so we can keep shipping new functionality in minor releases. | ||
|
Check warning on line 10 in docs/pages/blog/pattern-type-only-breaking-changes-minor-versions.md
|
||
|
|
||
| ## Context | ||
|
|
||
| Recently, we introduced our new [Range Bar chart](https://mui.com/x/react-charts/range-bar/) which has a new type to represent its data points: | ||
|
Check warning on line 14 in docs/pages/blog/pattern-type-only-breaking-changes-minor-versions.md
|
||
|
|
||
| ```tsx | ||
| /** [start, end] */ | ||
| type RangeBarValueType = [number, number]; | ||
| ``` | ||
|
|
||
| If you want to add a range bar chart, you need to use a bar chart from the Premium plan: | ||
|
|
||
| ```tsx | ||
| <BarChartPremium | ||
| xAxis={[{ data: months }]} | ||
| series={[ | ||
| { | ||
| type: 'rangeBar', | ||
| data: [ | ||
| [13, 21], | ||
| [17, 25], | ||
| ], | ||
| }, | ||
| ]} | ||
| /> | ||
| ``` | ||
|
|
||
| The bar chart accepts an `onAxisClick` prop, which is called when an user clicks on the chart. This callback is invoked with the event that triggered the click and another argument containing information about the axis section that was clicked. This information is typed as `ChartsAxisData | null`, its definition being as follows: | ||
|
|
||
| ```tsx | ||
| type OnAxisClickCallback = (event: MouseEvent, data: null | ChartsAxisData) => void; | ||
|
|
||
| interface ChartsAxisData { | ||
| dataIndex: number; | ||
| axisValue: number | Date | string; | ||
| /** | ||
| * The mapping of series IDs to their value for this particular axis index. | ||
| */ | ||
| seriesValues: Record<string, number | null | undefined>; | ||
| } | ||
| ``` | ||
|
|
||
| The `seriesValues` property of `ChartsAxisData` is a mapping from series ID to the value of that series for the clicked axis section. However, data points in range bar charts must conform to the `RangeBarValueType` type we saw above. | ||
|
Check warning on line 53 in docs/pages/blog/pattern-type-only-breaking-changes-minor-versions.md
|
||
|
|
||
| When adding the range bar chart, we attempted to widen the type union: | ||
|
Check warning on line 55 in docs/pages/blog/pattern-type-only-breaking-changes-minor-versions.md
|
||
|
|
||
| ```tsx | ||
| interface ChartsAxisData { | ||
| dataIndex: number; | ||
| axisValue: number | Date | string; | ||
| seriesValues: Record<string, RangeBarValueType | number | null | undefined>; | ||
| // ^^^^^^^^^^^^^^^^^ | ||
| // Added `RangeBarValueType` here | ||
| } | ||
| ``` | ||
|
|
||
| However, this would cause type errors in cases such as these: | ||
|
|
||
| ```tsx | ||
| function RangeBarChart() { | ||
| const [seriesValues, setSeriesValues] = useState<number | null | undefined>(); | ||
|
|
||
| return ( | ||
| <BarChartPremium | ||
| xAxis={[{ data: months }]} | ||
| series={[ | ||
| { | ||
| type: 'rangeBar', | ||
| data: [ | ||
| [13, 21], | ||
| [17, 25], | ||
| ], | ||
| }, | ||
| ]} | ||
| onAxisClick={(_event, data) => setSeriesValues(data?.seriesValues)} | ||
| /* TS2345: Argument of type ^^^^^^^^^^^^^^^^^^ | ||
| * Record<string, number | RangeBarValueType | null | undefined> | undefined | ||
| * is not assignable to parameter of type SetStateAction<number | null | undefined> | ||
| * Type Record<string, number | RangeBarValueType | null | undefined> | ||
| * is not assignable to type SetStateAction<number | null | undefined> | ||
| */ | ||
| /> | ||
| ); | ||
| } | ||
| ``` | ||
|
|
||
| ## Solution | ||
|
|
||
| The solution we found for this issue relies on TypeScript's module augmentation and interface merging features. | ||
|
Check warning on line 99 in docs/pages/blog/pattern-type-only-breaking-changes-minor-versions.md
|
||
| We can leverage the latter to add more properties or widen the type of a property in an interface. | ||
|
Check warning on line 100 in docs/pages/blog/pattern-type-only-breaking-changes-minor-versions.md
|
||
| An initial approach could look like this: | ||
|
bernardobelchior marked this conversation as resolved.
|
||
|
|
||
| ```diff | ||
| interface ChartsAxisData { | ||
| // ... | ||
| seriesValues: Record<string, number | null | undefined>; | ||
| } | ||
| + | ||
| + interface ChartsAxisData { | ||
| + seriesValues: Record<string, RangeBarValueType | number | null | undefined>; | ||
| + } | ||
| ``` | ||
|
|
||
| However, this doesn't work because we're changing the type of `seriesValues`. We can only widen the type or add more properties to the interface. | ||
|
Check warning on line 114 in docs/pages/blog/pattern-type-only-breaking-changes-minor-versions.md
|
||
|
|
||
| ```tsx | ||
| interface ChartsAxisData { | ||
| // ... | ||
| seriesValues: Record<string, number | null | undefined>; | ||
| } | ||
|
|
||
| interface ChartsAxisData { | ||
| seriesValues: Record<string, RangeBarValueType | number | null | undefined>; | ||
| // ^^^^^^^^^ | ||
| // TS2717: Subsequent property declarations must have the same type. | ||
| // Property seriesValues must be of type Record<string, number | null | undefined>, | ||
| // but here has type Record<string, number | RangeBarValueType | null | undefined> | ||
| } | ||
| ``` | ||
|
|
||
| So this is what we came up with: | ||
|
|
||
| ```tsx | ||
| export interface ChartsTypeFeatureFlags {} | ||
| type HasProperty<T, K extends string> = K extends keyof T ? true : false; | ||
|
|
||
| export interface ChartsAxisData { | ||
| dataIndex: number; | ||
| axisValue: number | Date | string; | ||
| /** | ||
| * The mapping of series ids to their value for this particular axis index. | ||
| */ | ||
| seriesValues: Record< | ||
| string, | ||
| HasProperty<ChartsTypeFeatureFlags, 'seriesValueOverride'> extends true | ||
| ? // @ts-ignore this property is added through module augmentation | ||
| ChartsTypeFeatureFlags['seriesValuesOverride'] | ||
| : number | null | undefined | ||
| >; | ||
| } | ||
| ``` | ||
|
|
||
| The original `ChartsAxisData` now depends on `ChartsTypeFeatureFlags` having a `seriesValuesOverride` property. If this property is present, `seriesValues` becomes `Record<string, ChartsTypeFeatureFlags['seriesValuesOverride']>`. However, if it's missing, it defaults to `Record<string, number | null | undefined>`. | ||
|
|
||
| Now, we just need to find a way to set `seriesValuesOverride` in `ChartsTypeFeatureFlags`. | ||
|
|
||
| As mentioned before, we can use interface merging to add more properties to an interface, so we leverage that plus module augmentation to add the property from a separate file that users can import if they need it: | ||
|
|
||
| ```tsx | ||
| declare module '@mui/x-charts/models' { | ||
| interface ChartsTypeFeatureFlags { | ||
| seriesValuesOverride: RangeBarValueType | number | null | undefined; | ||
| } | ||
| } | ||
|
|
||
| export default {}; | ||
| ``` | ||
|
|
||
| Users just need to import the file above (for example, `import type {} from '@mui/x-charts-premium/moduleAugmentation/rangeBarOnClick`) and the `seriesValues` type will be correct! If the file isn't imported, the types remain unchanged. | ||
|
|
||
| Users that opt in to using the range bar chart and import the file will now experience a type error, but developers who don't use a range bar chart don't have to do anything and their application will continue to function and type-check. | ||
|
|
||
| Borrowing a previous example, this is how it would look like after importing the module augmentation file: | ||
|
|
||
| ```tsx | ||
| import type {} from '@mui/x-charts-premium/moduleAugmentation/rangeBarOnClick'; | ||
| // ^^ Import the module augmentation | ||
|
|
||
| function RangeBarChart() { | ||
| const [seriesValues, setSeriesValues] = useState< | ||
| RangeBarValueType | number | null | undefined | ||
| >(); | ||
| // ^^^^^^^^^^^^^^^^^ | ||
| // Correct the type | ||
|
|
||
| return ( | ||
| <BarChartPremium | ||
| xAxis={[{ data: months }]} | ||
| series={[ | ||
| { | ||
| type: 'rangeBar', | ||
| data: [ | ||
| [13, 21], | ||
| [17, 25], | ||
| ], | ||
| }, | ||
| ]} | ||
| onAxisClick={(_event, data) => setSeriesValues(data?.seriesValues)} | ||
| // ^^^^^^^^^^^^^^^^^^ | ||
| // No more type issues here | ||
| /> | ||
| ); | ||
| } | ||
| ``` | ||
|
|
||
| Unfortunately, this solution isn't perfect, and comes with the following trade-offs: | ||
|
|
||
| - **Type soundness**: if the library consumer uses the range bar chart's `onAxisClick` but doesn't import the module augmentation, the type will be displayed incorrectly as `number | null | undefined`, which might cause a runtime error. This drawback is mitigated by a clear callout in the bar range docs to import module augmentation. | ||
| - **Global module augmentation**: module augmentation is global, which means that once you import it, all other usages of the augmented types are affected. So if you import it to fix a range bar chart's `onAxisClick` type, then all other usages of `onAxisClick` will have this new type, potentially causing type errors. | ||
|
|
||
| For us, these trade-offs are acceptable since we'd rather release features earlier so our users can benefit from them. In the next major version, we will have the opportunity to clean up this tech debt and become leaner once more. | ||
|
|
||
| If you find any problems with this solution or if you would like to provide any feedback, open an issue in the [MUI X](https://github.com/mui/mui-x) repo. | ||
Uh oh!
There was an error while loading. Please reload this page.