← Back to Blog

How Do You Safely Parse Dates with dayjs in TypeScript?

By Nark Team

Always call dayjs(input).isValid() before using the result of dayjs(input), and use strict-mode parsing (dayjs(input, format, true)) when the input has a known format. dayjs uses lenient parsing by default, which means it silently accepts overflowed values like "2025-13-99" and returns a valid Day.js object pointing at a completely different date — 2026-04-09 in that example. The isValid() check catches unparseable strings, but only strict mode catches semantic typos.

Quick Answer: dayjs(rawInput).isValid() catches "undefined", "TODO", empty strings, and non-date text — all return false. But dayjs("2025-13-99") returns true because dayjs overflows month 13 into next year. Use dayjs(input, "YYYY-MM-DD", true) (third arg = strict mode) when the format is known. To check for missing isValid() calls across your codebase: npx nark --tsconfig ./tsconfig.json.


Does dayjs Throw on Invalid Input?

No. dayjs never throws. Per the dayjs parsing documentation, when the input cannot be parsed, dayjs returns an Invalid Day.js object instead. The Invalid object behaves like a normal Day.js object — you can chain methods on it without error — but every accessor returns NaN, every format returns "Invalid Date", and every comparison returns false.

This silent-failure behavior is the opposite of Date's RangeError or Zod's thrown ZodError. It's deliberate (dayjs aims for fluent chaining), but it means an unhandled invalid date can propagate through your code untouched and surface as "Invalid Date" in a UI or as NaN in a calculation.

import dayjs from 'dayjs';

// No exception thrown
const date = dayjs('not-a-real-date');

date.year();           // NaN
date.format();         // "Invalid Date"
date.add(1, 'day');    // Returns another Invalid Day.js object
date.isAfter(dayjs()); // false

The only way to detect this is to ask explicitly: date.isValid().


What Strings Does dayjs.isValid() Actually Catch?

isValid() returns false for:

InputisValid()Why
"not-a-real-date"falseNo numeric date components found
"undefined"falseString literally containing "undefined"
"TODO"falsePlaceholder text
"YYYY-MM-DD"falseFormat string accidentally used as value
""falseEmpty string
nullfalseSpecial case, but only without a fallback

isValid() returns true for:

InputisValid()Parsed As
"2020-01-01"true2020-01-01 (correct)
"2025-13-99"true2026-04-09 (month overflow)
"2025-02-30"true2025-03-02 (day overflow)
dayjs() (no arg)trueCurrent date (always valid)

The first two rows of the second table are where most production bugs hide.


Why Does dayjs("2025-13-99") Return a Valid Date?

dayjs uses lenient parsing by default. When the parser sees a month value greater than 12 or a day value greater than the month's actual length, it carries the overflow into the next unit instead of throwing.

import dayjs from 'dayjs';

// Month 13 = January of next year + extra month
dayjs('2025-13-15').format('YYYY-MM-DD');  // "2026-01-15"

// Day 99 = month 4 (99 days past month 0 = April 9)
dayjs('2025-01-99').format('YYYY-MM-DD');  // "2025-04-09"

// Combined: month 13 + day 99 from Jan 2025 = April 2026
dayjs('2025-13-99').format('YYYY-MM-DD');  // "2026-04-09"

// Feb 30 doesn't exist, overflows to March
dayjs('2025-02-30').format('YYYY-MM-DD');  // "2025-03-02"

This is consistent with how JavaScript's new Date() works, but most developers expect a "date library" to reject impossible dates. It doesn't — by default. The reason is performance and ergonomics: lenient parsing handles real-world messy input (database-exported timestamps, user-typed forms) without forcing a try/catch at every call site.

The cost: a typo in a release script that should read 2025-12-15 but accidentally reads 2025-13-15 will produce a valid-looking January date for next year. Your CI passes. Your release notes are wrong.


How Do You Write a Strict dayjs Parser in TypeScript?

dayjs supports strict mode via the third argument to its constructor. Strict mode rejects overflowed dates and unrecognized formats. You also need the customParseFormat plugin — strict mode requires it.

import dayjs from 'dayjs';
import customParseFormat from 'dayjs/plugin/customParseFormat';

dayjs.extend(customParseFormat);

// Lenient (default) — accepts overflow
dayjs('2025-13-99').isValid();                       // true

// Strict — rejects overflow
dayjs('2025-13-99', 'YYYY-MM-DD', true).isValid();   // false
dayjs('2025-02-30', 'YYYY-MM-DD', true).isValid();   // false
dayjs('2025-01-15', 'YYYY-MM-DD', true).isValid();   // true

For inputs where the format is known and overflowed values are real bugs (release tags, calendar entries, financial dates), strict mode catches them at parse time.

When the input format isn't fixed (user-typed forms, multiple historical formats), lenient mode + isValid() is the right pattern, and you accept that semantic overflow can sneak through.

function parseChangelogDate(rawDate: string): Date | null {
  const date = dayjs(rawDate, 'YYYY-MM-DD', true);
  if (!date.isValid()) {
    return null;
  }
  return date.toDate();
}

This pattern returns null for both unparseable strings and overflowed dates — the caller decides how to surface the failure.


A Real-World Diagnostic Bug This Fix Prevents

This pattern showed up while auditing a popular UI library's release-CI script. The script reads the top date entry from CHANGELOG.en-US.md and verifies it falls within ±2 days of the current clock:

// Before
const date = dayjs(text.trim().replace('`', '').replace('`', ''));
if (date.isBetween(dayjs().add(-2, 'day'), dayjs().add(2, 'day'))) {
  console.log('Check Passed');
  process.exit(0);
}
console.log('The date wrongly written');
process.exit(1);

The problem: three distinct failure modes all hit the same error message.

  1. Malformed string ("TODO", accidental placeholder): dayjs(text).isValid() === false, isBetween() returns false, fall through to "wrongly written".
  2. Valid date, out of range (last week's date pasted by mistake): isValid() === true, isBetween() returns false, fall through to "wrongly written".
  3. Semantic typo (2025-13-15 instead of 2025-12-15): isValid() === true under lenient mode, parses as January 2026, isBetween() returns false, fall through to "wrongly written".

When the release CI failed, a maintainer had to manually open the changelog to figure out which of the three cases fired. The fix:

// After
const rawDate = text.trim().replace('`', '').replace('`', '');
const date = dayjs(rawDate);
if (!date.isValid()) {
  console.log(`Changelog date "${rawDate}" could not be parsed`);
  process.exit(1);
}
if (date.isBetween(dayjs().add(-2, 'day'), dayjs().add(2, 'day'))) {
  console.log('Check Passed');
  process.exit(0);
}
console.log('The date wrongly written');
process.exit(1);

The new branch fires for unparseable input and reports the offending string. The semantic-typo case (case 3) still falls through to the legacy message — catching that would require migrating the script to strict mode, which is a wider refactor. The diagnostic is now unambiguous for case 1, which covers most accidental edits.


Common dayjs Validation Mistakes in Production Code

1. Calling .format() or .year() without checking isValid()

// Bug: NaN-year propagates to the database
const userBirthday = dayjs(req.body.birthday);
await prisma.user.update({
  where: { id },
  data: { birthYear: userBirthday.year() },  // could be NaN
});

2. Relying on isBetween() / isBefore() / isAfter() to "naturally" reject invalid dates

Comparison methods return false for Invalid dates. That looks like correct rejection in passing — until you write if (!date.isBefore(now)) expecting the opposite, and an Invalid date now passes your "is future" check.

const claimed = dayjs(req.body.claimedDate);

// Bug: Invalid claimedDate sneaks through
if (claimed.isAfter(now)) {
  return res.status(400).json({ error: 'Cannot claim future dates' });
}
// Falls through here for Invalid claimedDate

3. Validating with isValid() but constructing the value lazily

// Bug: rawDate could be undefined, but TypeScript doesn't catch
// the silent isValid()=false fallthrough
function logEvent(rawDate?: string) {
  const date = dayjs(rawDate);
  if (date.isValid()) {
    // dayjs() with undefined returns current date (always valid)
    // so this block runs even when caller forgot the arg
  }
}

When dayjs() is called with no arguments OR with undefined, it returns the current date — always valid. This is a footgun for code that destructures optional inputs.

4. Forgetting customParseFormat plugin in strict mode

// Bug: strict mode silently falls back to lenient without the plugin
dayjs('2025-13-99', 'YYYY-MM-DD', true).isValid();  // true (oops)

// Fix: extend the plugin first
import customParseFormat from 'dayjs/plugin/customParseFormat';
dayjs.extend(customParseFormat);
dayjs('2025-13-99', 'YYYY-MM-DD', true).isValid();  // false

How Do You Check for dayjs Validation Gaps Across a Codebase?

Manually grepping for dayjs( in a large TypeScript repo finds the call sites but doesn't tell you which ones are guarded by isValid(). A static analyzer that understands the dayjs Profile can flag the unguarded ones.

Nark ships a Nark Profile for dayjs that defines:

  • Every dayjs(input) call must be followed by an isValid() check before the result is consumed.
  • The check must precede comparison methods (isBefore, isAfter, isBetween), formatting methods (format, toISOString), and accessors (year, month, date).
  • No-argument dayjs() is exempt (always valid).
npx nark --tsconfig ./tsconfig.json

The scanner walks the TypeScript AST, tracks dayjs identifiers through assignments and chained calls, and reports the call sites that consume a Day.js object without verifying it first. The Profile is open source and reflects the parsing rules above; the same rules apply whether you adopt Nark or write your own check.


Frequently Asked Questions

Does dayjs.isValid() work for any input type?

Yes. It accepts strings, numbers (Unix timestamps), Date objects, and other Day.js objects. For each, it returns whether the underlying parser produced a valid moment. The pitfall is overflow under lenient parsing, not type coercion.

Can I make dayjs throw on invalid input?

Not directly. dayjs intentionally avoids exceptions. The closest pattern is a wrapper:

function strictDayjs(input: string, format: string): dayjs.Dayjs {
  const d = dayjs(input, format, true);
  if (!d.isValid()) {
    throw new Error(`Unparseable date: "${input}" (expected ${format})`);
  }
  return d;
}

When should I use Day.js vs Date vs Temporal?

Day.js is small (~2 KB gzipped), immutable, and fluent — good for app code that formats and compares user-facing dates. Native Date is sufficient for timestamps and Unix-epoch math. Temporal is the future-proof option once browsers ship it (2026+), with strict-by-default parsing built in. For greenfield TypeScript projects today, Day.js + strict mode is the pragmatic choice.

Does Day.js's lenient parsing also affect timezone strings?

Yes. dayjs("2025-01-15T10:00:00+99:00") returns a "valid" Day.js object with a normalized offset. Use strict mode or a parsing library like Zod's z.string().datetime() for ISO-8601 strings that must reject malformed offsets.


Try It Now

npx nark --tsconfig ./tsconfig.json

Nark scans your TypeScript project for missing isValid() checks and 165+ other package-specific error-handling patterns. The dayjs Nark Profile codifies the rules in this article — no setup required, no config to write.

If you want to see the underlying Profile, the dayjs Profile lives at github.com/nark-sh/nark-corpus under packages/dayjs/.