Repository: iamkun/dayjs
Pull request #2573 (2024-02-07 02:18:49)

Fix: Improve typing for min/max
I think it's good to be clear that `null` can be returned from `min`/`max`, but TypeScript is powerful enough to be even smarter about this. This PR updates the types so that at least _obvious_ scenarios where at least one date is passed don't then require `!` or other code smells.

As with all types as they get more complex, it's easy to miss things or forget scenarios. Please let me know if I've missed anything.

I wouldn't actually consider this a "fix" so much as an _improvement on existing types_ but I don't think it's a feature and since the prior revision was marked as a fix, I'm keeping in line with that change.

iamkun: Any live example of this type update, please?
bensaufley: @iamkun Happy to provide examples; is there a place in the codebase I should do it?

For me, the issue the previous PR introduced is this (example code):

```ts
const myDate = dayjs('2023-08-17');
const anotherDate = dayjs(userInput);

if (!anotherDate.isValidDate()) return null;

return dayjs.min(myDate, anotherDate).add(1, 'week');
```

With the prior change, TS tells me `.add` isn't safe because the result may be `null` (and thus that I should put a `?` before `.add`). But it will never be null; TypeScript reasonably guarantees that the two arguments passed in are both dayjs objects.

The function only takes Dayjs objects, but may technically be called with an empty array, in which case it will return `null`. But that's the only case in which it will return `null`. So if TS _knows_ it's receiving at least one argument, it can also know that the `min`/`max` functions will return a `Dayjs` object. The rules are, or seem to be:

- If `min`/`max` receives at least one `Dayjs` object, it will return a `Dayjs` object.
- If it receives zero objects, it will return `null`.
- It may be passed the array as separate args or as a single arg with an array inside it; this does not change the behavior

TypeScript doesn't always know whether an array has at least one value: `Dayjs[]` only says it's an array that _can only contain_ `Dayjs` objects, but it can contain zero or more. In these cases, TS can't know whether or not `min`/`max` will return `null` or a `Dayjs` object and must assume either can be returned. So:

- If args are `...Dayjs[]` or `Dayjs[]`: return `null | Dayjs`
- If args are an explicitly empty array (`min()`: `...never[]` or `min([])`: `never[]`): return only `null`
- If args are an array with at least one Dayjs element (`min(foo, bar, baz)`: `...[Dayjs, ...Dayjs]`) or `min([foo, bar, baz])`: `[Dayjs, ...Dayjs]`): return only `Dayjs`

Looking at the code now it looks like the function may also accept an explicit `null` (and return `null`). I can add typing for that, if it's valuable. That would be:

```ts
min(val: null): null;
max(val: null): null;
```

It does not look like `min([null])` would be valid, as the check for truthiness happens _before_ extracting args-as-array (so no `min(val: null[])`), and `.isValid()` is called on each element in the array, which would throw an error on `null`.

I know some codebases have "type tests" or similar but I couldn't find any here. If you would like something like that can you point me in their direction? I didn't see any in the original PR.

Patch:
diff --git a/README.md b/README.md
index b50a700..6ded439 100644
--- a/README.md
+++ b/README.md
@@ -115,37 +115,26 @@ Support this project by becoming a sponsor. Your logo will show up here with a l
   <img width="70" src="https://user-images.githubusercontent.com/17680888/197092231-2367b5eb-1e43-467e-a311-23f7cd97b086.png">
 </a>
 &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
-<a href="https://github.com/alan-eu" target="_blank">
-  <img width="70" src="https://avatars.githubusercontent.com/u/18175329?s=52&v=4">
+<a href="https://github.com/ken-swyfft" target="_blank">
+  <img width="70" src="https://avatars.githubusercontent.com/u/65305317?v=4">
 </a>
 &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
 <a href="https://opencollective.com/sight-and-sound-ministries" target="_blank">
   <img width="70" src="https://user-images.githubusercontent.com/17680888/232316426-cb99b4cf-0ccb-4e73-a6ce-e16dba6aadf4.png">
 </a>
 &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
-<a href="https://chudovo.com/" target="_blank">
-  <img width="70" src="https://images.opencollective.com/chudovo/3c866f5/logo/256.png?height=256">
-</a>
-&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
-<a href="https://github.com/ken-swyfft" target="_blank">
-  <img width="70" src="https://avatars.githubusercontent.com/u/65305317?v=4">
+<a href="https://www.exoflare.com/open-source/?utm_source=dayjs&utm_campaign=open_source" target="_blank">
+  <img width="70" src="https://user-images.githubusercontent.com/17680888/162761622-1407a849-0c41-4591-8aa9-f98114ec2092.png">
 </a>
 &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
 <a href="https://github.com/storyblok" target="_blank">
   <img width="70" src="https://avatars.githubusercontent.com/u/13880908?s=200&v=4">
 </a>
 &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
-<a href="https://www.exoflare.com/open-source/?utm_source=dayjs&utm_campaign=open_source" target="_blank">
-  <img width="70" src="https://user-images.githubusercontent.com/17680888/162761622-1407a849-0c41-4591-8aa9-f98114ec2092.png">
-</a>
-&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
 <a href="https://bestkru.com/" target="_blank">
   <img width="70" src="https://avatars.githubusercontent.com/u/159320286" alt="BestKru">
 </a>
-&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
-<a href="https://opencollective.com/anonstories" target="_blank"><img width="70" src="https://images.opencollective.com/anonstories/7e826c0/avatar/256.png"></a>
-&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
-<a href="https://opencollective.com/datawrapper" target="_blank"><img width="70" src="https://images.opencollective.com/datawrapper/c13e229/logo.png"></a>
+
 
 ## Contributors
 
diff --git a/docs/demo/index.js b/docs/demo/index.js
new file mode 100644
index 0000000..0e4a87d
--- /dev/null
+++ b/docs/demo/index.js
@@ -0,0 +1,3 @@
+import dayjs from 'dayjs'
+// basic usage
+dayjs().format()
diff --git a/src/plugin/negativeYear/index.js b/src/plugin/negativeYear/index.js
new file mode 100644
index 0000000..6a3fbea
--- /dev/null
+++ b/src/plugin/negativeYear/index.js
@@ -0,0 +1,23 @@
+export default (_, c, dayjs) => {
+  const proto = c.prototype
+
+  const parseDate = (cfg) => {
+    const { date } = cfg
+    if (typeof date === 'string' && date.charAt(0) === '-') {
+      const newDate = new Date(date.slice(1))
+      const fullYear = newDate.getFullYear()
+      if (date.indexOf(`-${fullYear}`) !== -1) {
+        return dayjs(newDate).subtract(fullYear * 2, 'year').toDate()
+      }
+      return date
+    }
+    return date
+  }
+
+  const oldParse = proto.parse
+  proto.parse = function (cfg) {
+    cfg.date = parseDate.bind(this)(cfg)
+    oldParse.bind(this)(cfg)
+  }
+}
+
diff --git a/types/plugin/minMax.d.ts b/types/plugin/minMax.d.ts
index 4c5f6dc..7d0827f 100644
--- a/types/plugin/minMax.d.ts
+++ b/types/plugin/minMax.d.ts
@@ -4,8 +4,19 @@ declare const plugin: PluginFunc
 export = plugin
 
 declare module 'dayjs' {
-  export function max(dayjs: Dayjs[]): Dayjs | null
-  export function max(...dayjs: Dayjs[]): Dayjs | null
-  export function min(dayjs: Dayjs[]): Dayjs | null
-  export function min(...dayjs: Dayjs[]): Dayjs | null
+  export function max(dayjs: [Dayjs, ...Dayjs[]]): Dayjs
+  export function max(noDates: never[]): null
+  export function max(maybeDates: Dayjs[]): Dayjs | null
+
+  export function max(...dayjs: [Dayjs, ...Dayjs[]]): Dayjs
+  export function max(...noDates: never[]): null
+  export function max(...maybeDates: Dayjs[]): Dayjs | null
+
+  export function min(dayjs: [Dayjs, ...Dayjs[]]): Dayjs
+  export function min(noDates: never[]): null
+  export function min(maybeDates: Dayjs[]): Dayjs | null
+
+  export function min(...dayjs: [Dayjs, ...Dayjs[]]): Dayjs
+  export function min(...noDates: never[]): null
+  export function min(...maybeDates: Dayjs[]): Dayjs | null
 }
diff --git a/types/plugin/negativeYear.d.ts b/types/plugin/negativeYear.d.ts
new file mode 100644
index 0000000..43bb868
--- /dev/null
+++ b/types/plugin/negativeYear.d.ts
@@ -0,0 +1,4 @@
+import {PluginFunc} from 'dayjs'
+
+declare const plugin: PluginFunc
+export = plugin
\ No newline at end of file


Test patch:
diff --git a/test/plugin/negativeYear.test.js b/test/plugin/negativeYear.test.js
new file mode 100644
index 0000000..a9713fa
--- /dev/null
+++ b/test/plugin/negativeYear.test.js
@@ -0,0 +1,52 @@
+import MockDate from 'mockdate'
+import dayjs from 'dayjs'
+import negativeYear from '../../src/plugin/negativeYear'
+import utc from '../../src/plugin/utc'
+import { REGEX_PARSE } from '../../src/constant'
+
+
+dayjs.extend(negativeYear)
+dayjs.extend(utc)
+
+beforeEach(() => {
+  MockDate.set(new Date())
+})
+
+afterEach(() => {
+  MockDate.reset()
+})
+
+describe('negativeYear', () => {
+  it('parses negative years', () => {
+    expect(dayjs('-2020-01-01').year()).toBe(-2020)
+    const date = '-2021/01/03'
+    const date2 = '01/03/-2021'
+    const date3 = '01-03--2021'
+    const d = date.match(REGEX_PARSE)
+    expect(dayjs(date).format('YYYY-MM-DD')).toBe('-2021-01-03')
+    expect(dayjs(date2).format('YYYY-MM-DD')).toBe('Invalid Date')
+    expect(dayjs(date3).format()).toBe('Invalid Date')
+    expect(d).toBe(null)
+  })
+
+  it('does not parse non-negative years', () => {
+    expect(dayjs('2020-01-01').year()).toBe(2020)
+  })
+
+  it('works with other plugins', () => {
+    expect(dayjs.utc('-2020-01-01').year()).toBe(-2020)
+  })
+
+  it('Add and subtract with negative years', () => {
+    expect(dayjs('-2006').add(1, 'y')).toEqual(dayjs('-2005'))
+    expect(dayjs('-2006').subtract(1, 'y')).toEqual(dayjs('-2007'))
+    expect(dayjs('-2006').add(1, 'y').format('YYYY')).toBe(dayjs('-2005').format('YYYY'))
+    expect(dayjs('-2006').subtract(1, 'y').format('YYYY')).toBe(dayjs('-2007').format('YYYY'))
+  })
+
+  it('Compare date with negative years', () => {
+    expect(dayjs('-2006').isAfter(dayjs('-2007'))).toBeTruthy()
+    expect(dayjs('-2006').isBefore(dayjs('-2005'))).toBeTruthy()
+    expect(dayjs('-2006').isSame('-2006')).toBeTruthy()
+  })
+})