Building a custom Angular pipe for flexible date formatting
Angular ships with a tidy collection of transformation helpers, and the DatePipe is often the first stop when a UI needs a friendly timestamp. Out of the box it accepts a format string and honours the active locale, which is fine for prototypes and small admin panels. Once a real product lands, the gaps start to show. A team in Sydney might want a "today" label that respects AEDT in winter and AEST in summer, while a backend returns ISO strings stripped of any zone marker. The default pipe quietly applies the browser zone, which is rarely what you want when shipping to customers in Adelaide or Cairns.
The built-in transformer leans on Unicode locale data that ships with Angular's internationalisation package. Many Australian engineering teams, especially in fintech and logistics, deliberately keep bundle weight low and skip @angular/localize. When that is the case, DatePipe will throw or fall back to a bare ISO string. A lean, custom pipe that does exactly what your product needs is often a cleaner answer than dragging the full i18n runtime into a single page.
The same logic applies to brand voice. A mining dashboard used by FIFO crews in the Pilbara wants "Tue 12 Mar, 0600" rather than "Tuesday, 12 March 2024, 6:00 am". A community noticeboard in Hobart prefers "12/03/25", while a government site in Canberra may be legally obliged to spell the month out in full. Each of these scenarios shares the same JavaScript Date input, yet the built-in pipe cannot serve them all without a forest of branching if statements in templates.
A focused, hand-written pipe is the cleanest path. It can wrap Intl.DateTimeFormat directly, fall back gracefully when a feature flag is off, and stay easy to unit-test. The walk-through below builds one from scratch, wires it into a small Angular component, and covers the moving parts that come up when the work is shipped to users across the country.
Why the built-in DatePipe is not always enough
The DatePipe is implemented in @angular/common and delegates to formatDate, which in turn calls Intl.DateTimeFormat. When @angular/localize is loaded, the pipe resolves the active locale from the runtime and produces strings such as "12/03/2025" for en-AU or "12 Mar 2025" when given the medium style. That is serviceable for a single-page marketing site built in Brisbane and never translated.
The trouble begins when the input is a string rather than a Date. The pipe accepts string | number | Date, but if the string is missing a zone designator, JavaScript treats it as local time. A backend that returns "2025-03-12T06:00:00" without a trailing "Z" will be read as 6 am in the user's browser, which can put a Melbourne tradie's morning roster an hour off when the same UI is opened on a phone in Perth.
A second issue is dependency weight. The DatePipe only requires @angular/common, but to actually translate a token such as "EEE" into a localised day name, the full i18n runtime must be present. Australian companies that ship mobile-first products and measure every kilobyte often strip the runtime. The pipe then stops working, and the team resorts to | date: 'dd/MM/yyyy', which is fine until a product owner in Parramatta asks for a full month name. A custom pipe accepts named presets stored in a constant object, so a designer can audit them in a single file and a reviewer can spot a stray "yyyy" the moment it lands in a pull request.
Scaffolding the project and pipe skeleton
Start with a recent Angular CLI workspace. Running ng new date-demo --standalone --routing=false --style=scss gives a clean, standalone-friendly project that works just as well in a small Brunswick agency as it does in a large bank in Barangaroo. The pipe can sit in a shared/pipes folder so the same file is reachable from feature folders later.
Inside the new project, generate the pipe with ng generate pipe shared/pipes/format-date --standalone. The flag keeps the pipe compatible with standalone components, which is the default in Angular 17 and later. The CLI produces a file with a @Pipe decorator and a transform method stubbed to return null. Replace that stub with a typed signature that advertises intent, such as (value: string | number | Date | null, preset: keyof DatePresets = 'short'): string | null.
A DatePresets constant lives next to the pipe, listing the named shapes the product is allowed to use. Something like { short: 'dd/MM/yyyy', long: 'EEEE, d MMMM yyyy', roster: 'EEE d MMM, HHmm' } is enough to cover the Australia Day banner, the Anzac Day dawn-service card, and the FIFO roster tile in the same app. By centralising the strings, the team gets a single place to change "dd/MM/yyyy" if the company ever standardises on ISO display order, and the pipe class itself can stay small, deferring the actual string assembly to a tiny helper that is easy to unit-test in isolation.
Implementing format tokens for Australian audiences
Once the skeleton is in place, the helper that does the actual string assembly is the heart of the work. The cleanest approach is to skip Angular's own token vocabulary and build a tiny parser that understands a small, opinionated set of placeholders: yyyy, yy, MMMM, MMM, MM, dd, EEEE, EEE, HH, mm. That is enough to satisfy every product brief that has come across the desk in a typical Australian product squad, from the cricket-scoring app used in Adelaide Oval press boxes to the council meeting minutes site for the City of Gold Coast.
Each token maps to a function that pulls a value from the Date. yyyy returns the four-digit year, MMMM the full English month name, EEE a three-letter weekday. The parser walks the format string character by character, accumulates non-token text verbatim, and emits the replaced token. The result is a pure function with no Intl calls, so the same helper can be reused on the server in a Node script that prepares printable PDF summaries for regional councils in Wagga Wagga or Burnie.
Edge cases matter. A null input returns null so templates can guard with *ngIf or the new @if block. A Date whose value is Invalid Date should produce an empty string rather than the literal word "Invalid", which has a habit of showing up in production screenshots that get shared on Slack by accident. For projects that need a second locale, swap the local arrays for a lookup keyed on the active culture, and the pipe still accepts a preset argument so templates stay readable: {{ roster.due | formatDate: 'roster' }} reads almost like a sentence.
Working with time zones across Australia
A continent that spans three time zones asks more of a date pipe than almost any other feature. AWST, ACST, and AEST all sit in the same country, and daylight saving shifts parts of the east coast by an hour between the first Sunday in April and the first Sunday in October. A booking widget for a regional airline flying Adelaide to Hobart cannot assume the user's browser zone matches the departure city. The pipe therefore needs a way to render a moment in a chosen zone, not just the moment the browser hands over.
The trick is to accept an optional timeZone argument on the transform method. When the argument is supplied, the helper can call Intl.DateTimeFormat('en-AU', { timeZone, ...formatOptions }).format(date) to produce the string in the requested zone. Because Intl ships with every modern browser, no extra polyfill is required for the typical Melbourne or Brisbane user, which keeps the bundle lean. For the small set of older Android devices still in use around regional Western Australia, the fallback can degrade to a UTC render plus a suffix such as "AWST".
A common pattern in Australian SaaS products is to store everything in UTC and convert on display, and the custom pipe fits that pattern neatly. The dashboard can pass 'Australia/Sydney' for the east-coast operations team, 'Australia/Adelaide' for the South Australian warehouse, and 'Australia/Perth' for the fly-in fly-out crew heading to the Pilbara. DST edge cases still deserve attention, since the first Sunday in April at 3 am in Sydney jumps back to 2 am, meaning a timestamp of 2:30 am happens twice. A test that pins a known ambiguous timestamp to a known output is the cheapest way to prove the pipe is doing the right thing when the calendar page goes live.
Sharing the pipe across components and feature modules
Once the pipe works in isolation, the next step is to make it available where it is needed. Standalone pipes can be imported directly into a standalone component's imports array, which keeps the dependency surface obvious in the file that uses it. For a feature folder covering the public site of a Hobart tourism brand, that means the pipe is imported in the events component, the blog post component, and the tide table component, and nothing else sees it.
For larger surfaces such as a banking portal hosted out of Melbourne, the pipe can be re-exported from a SharedPipesModule that bundles a handful of common transformers. The module is then imported by every feature module that needs any of them. This keeps the build graph clean: a lazy-loaded chunk for the statements feature only pulls in the pipe if it is actually used in the statements view, rather than inheriting a kitchen-sink module at app bootstrap.
Tree shaking is worth a quick check, and a practical convention is to write each template usage as | formatDate: 'presetName', never as | formatDate: 'dd/MM/yyyy'. Preset names are easier to refactor, easier to lint, and easier to translate when the site eventually serves Te Reo Māori or Mandarin alongside English. A lint rule that flags any literal format string in a template is the same discipline applied to currency formatting in many Australian fintech codebases.
Verifying behaviour with Jasmine and Karma
A pipe is one of the easier units of Angular code to test, and it is worth doing thoroughly. The test bed is not even required: a plain describe block that calls pipe.transform(...) and asserts the returned string is enough for the formatting logic. A separate suite covers the time-zone code path with a fixed Date and a fixed timeZone argument, so the assertions do not drift when DST rules change.
Useful cases for the test file include a null input returning null, a '2025-03-12T06:00:00Z' input rendering as the correct local time in Sydney, a date in April 2025 at 2:30 am Sydney time rendering without crashing through the DST fold, and the long preset including the full weekday name. Each assertion is one or two lines, and the suite runs in well under a second, which keeps the feedback loop tight during the long winter evenings when many Brisbane developers dial in to stand-up.
For pipes that touch Intl, a small helper that freezes the current zone using Intl.DateTimeFormat.supportedLocalesOf(['en-AU']) confirms the test environment is sane. On a CI runner in another region, the local zone may differ from the developer's machine, and a test that hard-codes the rendered output can fail for the wrong reason. The fix is to pass an explicit timeZone to every test call, which mirrors how the production code is expected to be used, and the end-to-end check with Cypress or Playwright is worth one morning of effort so the next Anzac Day or Melbourne Cup preset lands with confidence.
Practical tips before you ship the pipe
- Keep the format vocabulary small and named. A handful of presets is easier to audit than a tower of Babel of token strings scattered across templates.
- Pass an explicit
timeZonewhenever the moment matters. Defaulting to the browser zone is fine for a "last updated" footer and dangerous for a booking flow. - Centralise the preset map in a single TypeScript file so designers, developers, and translators can review it without trawling through components.
- Write at least one DST edge-case test per zone you support. The second 2:30 am in Sydney or the lost hour in Adelaide is the kind of bug that only shows up twice a year.
- Prefer
Intl.DateTimeFormatover hand-rolled month and day arrays for anything beyond English. It already speaksen-AU,zh-Hans, and the dozen-plus other locales a product might need. - Keep the pipe pure. No service injection, no DOM access, no
console.log. A pure function is trivial to test, trivial to memoise, and trivial to move into a Web Worker if performance ever demands it.
Drop the finished pipe into your shared folder, import it where the dates need to shine, and watch the UI feel a little more Australian the moment the Anzac Day roster renders in plain English. If a preset is missing, add a line to the map, run the suite, and the next person to touch the file will thank you for keeping the shape so tidy.