# Mapping of data with Zod ## The problem Mapping data in Javascript/Typescript is not always easy. You need to create different kinds of checks(if/switch statements) to validate if your data has the shape x or y. You also need to find the difference between 2 shapes and use that difference to create your validation. Another thing that will be hard is using the correct types(in Typescript), there are some tricks to do that but those will add extra complexity to your project. In the end, you are creating a lot of extra code/complexity to make your mapper work. So what if you can use a library like [Zod](https://zod.dev/){rel=""nofollow""} to help with the mapping part? ```typescript const user = { id: '123', firstName: 'Stijn', lastName: 'Van Hulle', email: 'stijn@stijnvanhulle.be', } as const const twitterUser = { id: '123', first: 'Stijn', last: 'Van Hulle', email: 'stijn@stijnvanhulle.be', } as const const googleUser = { uuid: '123', info: { firstName: 'Stijn', lastName: 'Van Hulle', }, email: 'stijn@stijnvanhulle.be', } as const // no type support const mapData = (data: any) => { if (data.info) { // google user return { id: user.uuid, value: user.info.firstName + ' ' + user.info.lastName, } } else if (data.first && data.last) { return { id: user.id, value: user.first + ' ' + user.last, } } else if (data.firstName && data.lastName) { return { id: user.id, value: user.firstName + ' ' + user.lastName, } } } mapData(googleUser) /* => { id: '123', value: 'Stijn Van Hulle' } */ ``` ## What is Zod? Zod is described as: "TypeScript-first schema validation with static type inference " You can use Zod to validate a form, create type-safe schema's and create type-safe API's. One example of a library that is using Zod is [TRPC](https://trpc.io/){rel=""nofollow""}. With TRPC, you specify your schema once on the back-end. And then in the front-end, you can use the same schema to have a type-safe experience for doing API calls. While there are many TypeScript [schema validation libraries](https://zod.dev/?id=comparison){rel=""nofollow""} out there, Zod has some extra features that can assist you in creating a type-safe environment. Zod has out-of-the-box Typescript support which means you can create a schema and let Typescript infer the type based on the schema. All of this will be useful for creating a mapper based on Zod. ### Zod primitives The following code is a simple example of how you can use Zod to check if a value is a string. ```typescript import { z } from 'zod' // creating a schema for strings const mySchema = z.string() // parsing mySchema.parse('tuna') // => "tuna" mySchema.parse(12) // => throws ZodError // "safe" parsing (doesn't throw error if validation fails) mySchema.safeParse('tuna') // => { success: true; data: "tuna" } mySchema.safeParse(12) // => { success: false; error: ZodError } ``` ### Zod object Another thing you can do with Zod is creating objects and validate if the returned shape is equal to the one specified. ```typescript import { z } from 'zod' const User = z.object({ username: z.string(), }) User.parse({ username: 'Ludwig' }) // extract the inferred type type User = z.infer // { username: string } User.parse({ firstname: 'firstname' }) // => throws ZodError ``` ### Zod transform Zod can also map an object/primitives from a specific shape to another shape. The following example will return the origin of an email address. ```typescript const emailToDomain = z .string() .email() .transform((val) => val.split('@')[1]) emailToDomain.parse('colinhacks@example.com') // => example.com ``` ## Mapping data ### Simple Zod mapper Now that we have a basic understanding of Zod, we can start with creating a mapper schema. We will use the basic primitives together with the transform functionality to map our data. 1. Let's say we have the following input and we want to map this to an object that only contains *id* and *value*(full name of an user). ```typescript const user = { id: '123', firstName: 'Stijn', lastName: 'Van Hulle', email: 'stijn@stijnvanhulle.be', } ``` 2. We can use the previously described transforms to map the user(containing *id* and *value*). ```typescript import z from 'zod' export const userSchema = z .object({ id: z.string(), firstName: z.string(), lastName: z.string(), email: z.string(), }) .transform((user) => { return { id: user.id, value: user.firstName + ' ' + user.lastName, } }) ``` 3. The last thing we need to do is call *.parse* with *user* as the first parameter and that will return the mapped data. ```typescript userSchema.parse(user) /* => { id: '123', value: 'Stijn Van Hulle' } */ ``` ### Multiple inputs mapper But what if we have multiple inputs with different shapes/schemas? ```typescript import z from 'zod' const user = { id: '123', firstName: 'Stijn', lastName: 'Van Hulle', email: 'stijn@stijnvanhulle.be', } const twitterUser = { id: '123', first: 'Stijn', last: 'Van Hulle', email: 'stijn@stijnvanhulle.be', } export const userSchema = z .object({ id: z.string(), firstName: z.string(), lastName: z.string(), email: z.string(), }) .transform((user) => { return { id: user.id, value: user.firstName + ' ' + user.lastName, } }) export const twitterUserSchema = z .object({ id: z.string(), first: z.string(), last: z.string(), email: z.string(), }) .transform((user) => { return { id: user.id, value: user.first + ' ' + user.last, } }) userSchema.parse(user) /* => { id: '123', value: 'Stijn Van Hulle' } */ twitterUserSchema.parse(twitterUser) /* => { id: '123', value: 'Stijn Van Hulle' } */ ``` ### Multiple unknown inputs mapper But what if we don't know what the user's shape will be? Let's say we have 2 API calls: - One to our database - One to an external API(for example Twitter). We can, of course, create some checks to see where the data is coming from but that will not make it scalable and we will have the same issue as before: we need to create if statements to check the input before we can convert it. ```typescript import z from 'zod' const user = { id: '123', firstName: 'Stijn', lastName: 'Van Hulle', email: 'stijn@stijnvanhulle.be', } const twitterUser = { id: '123', first: 'Stijn', last: 'Van Hulle', email: 'stijn@stijnvanhulle.be', } export const userSchema = z .object({ id: z.string(), firstName: z.string(), lastName: z.string(), email: z.string(), }) .transform((user) => { return { id: user.id, value: user.firstName + ' ' + user.lastName, } }) export const twitterUserSchema = z .object({ id: z.string(), first: z.string(), last: z.string(), email: z.string(), }) .transform((user) => { return { id: user.id, value: user.first + ' ' + user.last, } }) const mapData = (data: any) => { if (data.first) { // twitter twitterUserSchema.parse(data) } else { // database user return userSchema.parse(data) } } mapData(twitteruser) /* => { id: '123', value: 'Stijn Van Hulle' } */ mapData(user) /* => { id: '123', value: 'Stijn Van Hulle' } */ ``` ### Zod unions This will do the trick but what if we have another source, for example, user data coming from Google? Do we again want to create another if statement? That feels hard to maintain so why not use the power of Zod and let Zod figure out what the shape and type will be? In Zod you can use unions(also used with an .or function) and Zod will figure out the shape. ```typescript const schema = z.string().or(z.number()) // string | number // equivalent to z.union([z.string(), z.number()]) stringOrNumber.parse('foo') // passes stringOrNumber.parse(14) // passes ``` ### End result Combine that with transforms and you can use the power of Zod to do the hard work in finding the correct schema/shape. So with our previous example, we can simplify that to the following: ```typescript const user = { id: '123', firstName: 'Stijn', lastName: 'Van Hulle', email: 'stijn@stijnvanhulle.be', } as const const twitterUser = { id: '123', first: 'Stijn', last: 'Van Hulle', email: 'stijn@stijnvanhulle.be', } as const const googleUser = { uuid: '123', info: { firstName: 'Stijn', lastName: 'Van Hulle', }, email: 'stijn@stijnvanhulle.be', } as const ``` ```typescript import z from 'zod' export const userSchema = z .object({ id: z.string(), firstName: z.string(), lastName: z.string(), email: z.string(), }) .transform((user) => { return { id: user.id, value: user.firstName + ' ' + user.lastName, } }) export const twitterUserSchema = z .object({ id: z.string(), first: z.string(), last: z.string(), email: z.string(), }) .transform((user) => { return { id: user.id, value: user.first + ' ' + user.last, } }) export const googleUserSchema = z .object({ uuid: z.string(), info: z.object({ firstName: z.string(), lastName: z.string(), }), email: z.string(), }) .transform((user) => { return { id: user.uuid, value: user.info.firstName + ' ' + user.info.lastName, } }) ``` ```typescript const mapData = (data: any) => twitterUserSchema.or(userSchema).or(googleUserSchema).parse(data) export const users = [mapData(twitterUser), mapData(user), mapData(googleUser)] ``` [![Edit zod-mapper-object](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/jovial-worker-dl978e?fontsize=14&hidenavigation=1&theme=dark){rel=""nofollow""} ### Summary The magic here is that you can add as many unions as you need(so long there is no overlap). Zod will throw an error if it cannot find a schema that is matching the input. This will make it scalable and in the end, you have still control over the types. This approach can help you with creating mappers in Javascript but in the end, you should keep things as easy as possible. Using x amount of different schema's/types will make your application harder to maintain so think twice before using something like this. Zod can do a lot, so it's worth exploring what the library offers. For a deeper look, I'd suggest this tutorial: {rel=""nofollow""}. # Node version numbers now match the calendar year Node.js [changed its release schedule](https://nodejs.org/en/blog/announcements/evolving-the-nodejs-release-schedule){rel=""nofollow""} for the first time in about ten years. The part that touches daily work most is the version number: starting with Node 27, the major version matches the calendar year it ships in. ## What changes Node moves from two major releases a year to one, every April, promoted to LTS that October. The major number is the year minus 2000, so Node 27 ships in 2027 and Node 28 in 2028. Each line opens as an alpha six months early. Node 27 lands as `27.0.0-alpha.1` in October 2026, takes its semver-major changes there, ships as `27.0.0` in April 2027, and goes LTS that October. Node 26 is the last release numbered the old way. ::div{.doc-viz dataAnimate="true"} Release schedule from Node 27 :::div{.rel-row} ::::div{.rel-name} Node **27** :::: ::::div{.rel-track} :::::div{.rel-bar style="--l:10.71%;--w:50%;--i:0"} ::::::div{.rel-seg.rel-alpha} alpha :::::: ::::::div{.rel-seg.rel-cur} Current :::::: ::::::div{.rel-seg.rel-lts} LTS · 30 months :::::: ::::: :::: ::: :::div{.rel-row} ::::div{.rel-name} Node **28** :::: ::::div{.rel-track} :::::div{.rel-bar style="--l:25%;--w:50%;--i:1"} ::::::div{.rel-seg.rel-alpha} alpha :::::: ::::::div{.rel-seg.rel-cur} Current :::::: ::::::div{.rel-seg.rel-lts} LTS · 30 months :::::: ::::: :::: ::: :::div{.rel-row} ::::div{.rel-name} Node **29** :::: ::::div{.rel-track} :::::div{.rel-bar style="--l:39.29%;--w:50%;--i:2"} ::::::div{.rel-seg.rel-alpha} alpha :::::: ::::::div{.rel-seg.rel-cur} Current :::::: ::::::div{.rel-seg.rel-lts} LTS · 30 months :::::: ::::: :::: ::: :::div{.rel-axis} ::::div :::: ::::div{.rel-years} [2026][2027][2028][2029][2030][2031][2032][2033] :::: ::: One line a year, and the major number is the year it ships. Six months as Current, then 30 months of LTS: 36 months from release to end of life. :: ## The number is the year Quick: what year did Node 18 ship? I can never remember, and most people cannot either. The major is an opaque counter today, so the number tells you nothing about how old a runtime is. [Ubuntu](https://ubuntu.com){rel=""nofollow""} settled this years ago, where `24.04` reads as April 2024 at a glance, and Node now does the same. A small piece of luck makes the switch clean. Today's major, 26, already matches the year, 2026. Moving to exactly one release a year keeps them locked together from here: 27 in 2027, 28 in 2028. The number also carries the support window. Node 27 is a 2027 line, so it reaches end of life in April 2030: six months as Current from April 2027, then 30 months of LTS, 36 months in total. Read the year off the major, add that fixed window, and you have the dates without a calendar. That sounds cosmetic until you write the upgrade ticket. "We're three majors behind" used to be vague, because odd-numbered lines like 21 and 23 never reached LTS and most teams skipped them, so a gap in major numbers did not map to time. Now three majors behind is three years behind, and everyone reads the number the same way. ## The number shows up everywhere The version number lands in every `engines` range, every CI matrix, and every "which Node are we on" conversation. Once it carries the year, each of those reads as a date instead of an arbitrary count. No cross-referencing a release calendar to work out what a number means. It is a small, boring change, and that is the highest praise I can give a versioning scheme. The schedule stops being something you track and the number starts doing the remembering for you. ## References - [Evolving the Node.js release schedule](https://nodejs.org/en/blog/announcements/evolving-the-nodejs-release-schedule){rel=""nofollow""} - [Node.js previous releases and the support calendar](https://nodejs.org/en/about/previous-releases){rel=""nofollow""} # Preventing npm supply chain attacks A new npm compromise lands every few weeks, and each one follows the same: steal a maintainer's credentials, publish a poisoned version, let the registry fan it out to everyone before anyone notices. ::div{.doc-viz dataAnimate="true"} How one compromise reaches everyone :::div{.dv-flow.atk-track} ::::div{.dv-node.dv-node--danger style="--i:0"} Phished maintainer :::: ::::div{.dv-arrow style="--i:1"} :::: ::::div{.dv-node.dv-node--danger style="--i:2"} Poisoned version :::: ::::div{.dv-arrow style="--i:3"} :::: ::::div{.dv-node style="--i:4"} npm registry :::: ::::div{.atk-pulse} :::: ::: :::div{.atk-fan} ::::div{.atk-dot style="--i:0"} :::: ::::div{.atk-dot style="--i:1"} :::: ::::div{.atk-dot style="--i:2"} :::: ::::div{.atk-dot style="--i:3"} :::: ::::div{.atk-dot style="--i:4"} :::: ::::div{.atk-dot style="--i:5"} :::: ::::div{.atk-dot style="--i:6"} :::: ::::div{.atk-dot style="--i:7"} :::: ::::div{.atk-dot style="--i:8"} :::: ::::div{.atk-dot style="--i:9"} :::: ::::div{.atk-dot style="--i:10"} :::: ::::div{.atk-dot style="--i:11"} :::: ::::div{.atk-dot style="--i:12"} :::: ::::div{.atk-dot style="--i:13"} :::: ::::div{.atk-dot style="--i:14"} :::: ::::div{.atk-dot style="--i:15"} :::: ::: One stolen login publishes a poisoned version, and the registry pushes it to every install before a human catches it. :: The [`chalk` and `debug` compromise](https://semgrep.dev/blog/2025/chalk-debug-and-color-on-npm-compromised-in-new-supply-chain-attack/){rel=""nofollow""} in September 2025 poisoned packages with 2.6 billion weekly downloads after a single maintainer fell for a 2FA-reset phishing email. The [Shai-Hulud worm](https://www.sysdig.com/blog/shai-hulud-the-novel-self-replicating-worm-infecting-hundreds-of-npm-packages){rel=""nofollow""} self-replicated across more than 500 packages, including `@ctrl/tinycolor` and CrowdStrike's, by stealing tokens at install time and republishing through them. The [Nx postmortem](https://nx.dev/blog/nx-console-v18-95-0-postmortem){rel=""nofollow""} in March 2026 chained into an [AWS admin takeover](https://thehackernews.com/2026/03/unc6426-exploits-nx-npm-supply-chain.html){rel=""nofollow""}. The [Axios incident](https://www.microsoft.com/en-us/security/blog/2026/04/01/mitigating-the-axios-npm-supply-chain-compromise/){rel=""nofollow""} bypassed OIDC entirely by using a stolen npm token. And the [TanStack postmortem](https://tanstack.com/blog/npm-supply-chain-compromise-postmortem){rel=""nofollow""} covered a May 2026 sweep that used GitHub Actions cache poisoning to steal live OIDC tokens at publish time. The defense splits into three layers: what you decide to install, what your install tooling does to protect you, and how you stop your own releases from becoming the next incident. ## Do you need this dependency at all? The cheapest attack to defend against is the one where you never installed the package. Before you reach for `npm i`, `yarn add` or `pnpm add`, ask whether the dependency should exist. Could you replace it with *twenty* lines of your own code or even use AI to generate the code? Then look at the footprint. A package that pulls in fifty others adds fifty more maintainers who can get phished. Fewer dependencies, smaller attack surface. ## Read the maintainers, not just the README Open the GitHub repo and the Commits tab. Filter out bot noise. A two-year gap followed by a sudden burst of releases is the shape of an abandoned package that just changed hands, which is also the shape of an account takeover. Look at who is doing the work. If almost every commit and release comes from one person, you have maintainer concentration risk. One phished maintainer is the entire incident. Healthy release cadence is weekly, monthly, quarterly. What you do not want is a quiet year followed by three patch releases in a week. Check the latest release for a [provenance attestation](https://docs.npmjs.com/generating-provenance-statements){rel=""nofollow""}. The badge ties the tarball back to a specific commit, repo, and workflow run. A missing badge is not proof of compromise. But an attacker who does not control the repo cannot fake one. ## Watch out for slopsquatting > Slopsquatting is a supply chain attack where hackers register fake, malicious software packages that AI models frequently hallucinate, tricking developers or AI agents into installing them. AI coding assistants regularly hallucinate package names that do not exist, and attackers register the names ahead of time. This is called `slopsquatting` or `hallucination squatting`. Any LLM-suggested package deserves a thirty-second sanity check: open it on [npm](https://www.npmjs.com){rel=""nofollow""}, click through to the repo, and confirm the name maps to a real project. ## Installing Once you have decided a package is worth installing, your package manager is the next line of defense. [pnpm 11](https://pnpm.io/supply-chain-security){rel=""nofollow""} ships defaults that `npm` does not, so the focus here is `pnpm`. - [`minimumReleaseAge`](https://pnpm.io/settings#minimumreleaseage){rel=""nofollow""} is the biggest single setting. pnpm 11 defaults to 1440 minutes, refusing any version published in the last day. ::div{.markdown-alert.markdown-alert-tip dir="auto"} TIP npm has its own [`min-release-age`](https://docs.npmjs.com/cli/v11/using-npm/config#min-release-age){rel=""nofollow""} setting for the same job, added in npm 11.10.0. :: - [`strictDepBuilds`](https://pnpm.io/settings#strictdepbuilds){rel=""nofollow""} is the other one. On by default in pnpm 11, it means lifecycle scripts only run for packages you explicitly list in [`pnpm.onlyBuiltDependencies`](https://pnpm.io/package_json#pnpmonlybuiltdependencies){rel=""nofollow""}. Keep the lockfile committed, and never run CI with `--no-frozen-lockfile`. ::div{.markdown-alert.markdown-alert-tip dir="auto"} TIP Npm does not have this, but you can set `--ignore-scripts` to disable all scripts. :: In pnpm 11 these settings live in `pnpm-workspace.yaml`: ```yaml # pnpm-workspace.yaml minimumReleaseAge: 1440 minimumReleaseAgeStrict: true onlyBuiltDependencies: - esbuild ``` On npm, put the cooldown in `.npmrc`: ```ini # .npmrc min-package-age=72 ignore-scripts=true ``` ## Publishing The other half of the picture, if you maintain something on `npm`, is making sure your own releases cannot be hijacked. - The biggest single change is dropping `NPM_TOKEN` and switching to [npm OIDC trusted publishing](https://docs.npmjs.com/trusted-publishers){rel=""nofollow""}. GitHub mints a short-lived token per workflow run and npm verifies the signature. - OIDC is not a magic bullet. The token is short-lived, but it still sits in the runner during the job. TanStack got hit this way: attackers poisoned [Nx](https://nx.dev){rel=""nofollow""}'s GitHub Actions cache, the publish job restored it, and the malicious code read the live OIDC token. So treat the Actions cache as untrusted, and do not restore caches in the publish job. - [Pin every action to a 40-character commit SHA](https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-third-party-actions){rel=""nofollow""} (`actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6`) so a dependency cannot change under you between runs. - Stage releases before they hit `latest`. `pnpm stage publish` (pnpm 11.3+) splits upload from promotion, which gives you a window to catch a bad release. - Keep the publish job small. Run tests, lints, and installs on earlier jobs that do not have `id-token: write`. Every extra step inside the privileged job is one more place a token can leak. The publish job needs a couple of things, lifted from Kubb's [release.yml](https://github.com/kubb-labs/kubb/blob/eaef40b9aa6e5e9497811de1d5dd69df34179a1f/.github/workflows/release.yml){rel=""nofollow""}: ```yaml permissions: contents: write id-token: write packages: write env: NPM_CONFIG_PROVENANCE: true ``` ## Staged publishing Without staging, a compromised CI runner publishes straight to the `latest` tag. The bad version reaches everyone running `npm install` or `pnpm i` within minutes. [Staged publishing](https://pnpm.io/cli/publish){rel=""nofollow""} breaks that path in two. ::div{.doc-viz dataAnimate="true"} Staged publishing splits the path in two :::div{.stage} ::::div{.stage-lane} :::::div{.stage-lane__label} Without staging ::::: :::::div{.dv-flow} ::::::div{.dv-node style="--i:0"} CI runner :::::: ::::::div{.dv-arrow style="--i:1"} :::::: ::::::div{.dv-node.dv-node--danger style="--i:2"} latest tag :::::: ::::::div{.dv-arrow style="--i:3"} :::::: ::::::div{.dv-node.dv-node--danger style="--i:4"} every install :::::: ::::: :::: ::::div{.stage-lane} :::::div{.stage-lane__label} Staged publishing ::::: :::::div{.dv-flow} ::::::div{.dv-node style="--i:0"} CI runner :::::: ::::::div{.dv-arrow style="--i:1"} :::::: ::::::div{.dv-node style="--i:2"} staged version :::::: ::::::div{.dv-arrow style="--i:3"} :::::: ::::::div{.stage-gate style="--i:4"} 2FA approval :::::: ::::::div{.dv-arrow style="--i:5"} :::::: ::::::div{.dv-node.dv-node--safe style="--i:6"} latest tag :::::: ::::: :::: ::: A stolen token publishes straight to latest without staging. With staging the release parks until a maintainer promotes it behind a 2FA prompt that never reaches the runner. :: 1. First, CI uploads. [`pnpm stage publish`](https://pnpm.io/cli/publish){rel=""nofollow""} signs the release and pushes it to [npm](https://www.npmjs.com){rel=""nofollow""} as a staged version that no one can install yet. 2. Second, a human promotes it. A maintainer reviews the staged version and moves it to `latest`, and [npm requires 2FA](https://docs.npmjs.com/configuring-two-factor-authentication){rel=""nofollow""} for that step. That split is what saves you. An attacker who steals your tokens from a poisoned workflow still cannot promote the release. The 2FA prompt lands on your hardware key or authenticator, not the runner. The release sits in staging until someone with the key approves it, and that is your window to catch it and abort. ## The checklist Before installing: - Run the removal test. Do you actually need this package? - Check maintainer count, release cadence, and time since the last real commit. - Verify any LLM-suggested package name links to a real repo with provenance. At install time: - Use pnpm 11+ for the cooldown and the strict build allowlist. - Set `minimumReleaseAgeStrict: true` and keep `pnpm.onlyBuiltDependencies` short. - Install with `--ignore-scripts` wherever your tooling allows it. When you publish: - Enable OIDC trusted publishing and remove `NPM_TOKEN` from secrets. - Set `NPM_CONFIG_PROVENANCE: true` in the publish job. - Pin every action to a 40-character commit SHA. - Do not restore GitHub Actions caches in the publish job. - Stage releases via staged publishing before they hit `latest`. - Turn on [two-factor auth with a hardware key](https://docs.npmjs.com/configuring-two-factor-authentication){rel=""nofollow""} on your npm account. None of this stops a determined attacker forever. The point of layering it is simpler: one failure no longer ends in a poisoned release. # Rules, skills, commands, subagents, MCP Most people put everything in [`CLAUDE.md`](https://docs.claude.com/en/docs/claude-code/memory){rel=""nofollow""} and wonder why the agent skips half of it. The file gets long, the model gets lazy, and the rules you actually cared about disappear into the noise. The fix is knowing that [Claude Code](https://docs.claude.com/en/docs/claude-code/overview){rel=""nofollow""} has multiple building blocks, not one. Four are about guidance the agent reads. The fifth is about tools the agent can reach. Each has different rules about when it loads and what it costs. ## The five building blocks | Building block | Loaded | Cost | Use for | | ---------------------------------------------------------------------- | -------------------------------------- | --------------------------------------------------- | ----------------------------------------------------- | | [Rule](https://stijnvanhulle.be/#rules-the-always-on-layer) | Session start, full content | Every request | Coding standards, project layout, "always do X" facts | | [Skill](https://stijnvanhulle.be/#skills-the-reference-shelf) | Description at start, body when used | Description every request, body only when triggered | Multi-step procedures and long reference | | [Command](https://stijnvanhulle.be/#commands-workflows-you-type) | Body when you type it | Per invocation | Workflows you trigger by name | | [Subagent](https://stijnvanhulle.be/#subagents-isolated-workers) | When spawned | Isolated context, returns a summary | Research and heavy work that floods context | | [MCP server](https://stijnvanhulle.be/#mcp-servers-tools-from-outside) | Tool names at start, schemas on demand | Low until a tool is called | Issue trackers, databases, design tools | ## Rules: the always-on layer Rules live in `.claude/rules/`. In my template that's six files: `code style`, `JSDoc essentials`, `markdown`, `security`, `testing`, `USA English`. They load on every turn, so they need to be short. The test is simple. If forgetting it would produce code you'd reject in review, it's a rule. Everything else belongs elsewhere. `security.md` is the cleanest example. Six bullets, no examples, no preamble. Don't commit secrets. Validate input at trust boundaries. Don't shell-interpolate untrusted strings. That's the whole file. It loads every turn because the cost of forgetting any one of them is too high. Rules are not the place for linting or formatting. Spending context every turn to remind the agent about indentation or import order is wasteful when a tool checks it for free. Let [ESLint](https://eslint.org/){rel=""nofollow""} and [Oxlint](https://oxc.rs/docs/guide/usage/linter){rel=""nofollow""} catch lint violations and [Prettier](https://prettier.io/){rel=""nofollow""} or [oxfmt](https://oxc.rs/docs/guide/usage/formatter){rel=""nofollow""} handle formatting. A rule does not have to load on every turn. Add a `paths` field to the frontmatter and it only loads when the agent touches a matching file, so a markdown rule stays out of context until there's markdown in play. ```yaml --- paths: - "**/*.md" - "**/*.mdx" --- ``` ## Skills: the reference shelf [Skills](https://docs.claude.com/en/docs/claude-code/skills){rel=""nofollow""} are markdown files the agent loads only when the task matches. They can be long, because they only cost context when they fire. The template has a `jsdoc` skill that loads when I write TypeScript. It carries the full tag taxonomy: which `@example` format to use, when `@default` and `@deprecated` apply, what tags to skip, and the order they go in. That's far too much to keep in context every turn, but exactly what I want on hand the moment I'm documenting a function. It costs nothing until the task triggers it. The trigger lives at the top of `SKILL.md` as a short description. The agent reads the description on every turn but only opens the body when the task matches. That's the whole trick. ## Commands: workflows you type [Commands](https://docs.claude.com/en/docs/claude-code/slash-commands){rel=""nofollow""} are skills you invoke by name with a slash. `/code-review`, `/verify`, `/changeset`. Same file format as a skill, different entry point. Use them when you want the agent to do something specific on demand, and you don't want to retype the prompt every time. The `/code-review` command in the template is a good example. It knows the project's conventions, runs the right diff, and posts inline comments. ## Subagents: isolated workers [Subagents](https://docs.claude.com/en/docs/claude-code/sub-agents){rel=""nofollow""} are different. They run in their own context window. Use one when you want parallel work, or when the task would flood your main context with noise. `Explore` is the clearest example. If I ask it to find every place we read from `process.env`, it reads dozens of files and reports back two lines. My context stays clean. The anti-pattern is reaching for a subagent when two tool calls would do. Spawning costs latency and tokens. Don't pay it unless the work is genuinely big or parallel. ## MCP servers: tools from outside The first four building blocks are about guidance. [MCP servers](https://docs.claude.com/en/docs/claude-code/mcp){rel=""nofollow""} are different. They run as separate processes and expose tools the agent can call, like GitHub, a database, a browser, or your design system. You wire one up once in `.mcp.json` or your user config, and the tools it provides show up alongside the built-in ones. The template ships with the GitHub MCP server enabled because almost every project needs PR comments and issue reads. For codegen work I add the [Kubb MCP server](https://kubb.dev/docs/5.x/ai/mcp){rel=""nofollow""}. It exposes [Kubb](https://kubb.dev/){rel=""nofollow""}'s code-generation tools to the agent, so it can trigger generation, validate a schema, and inspect the config straight from chat instead of me dropping out to the CLI. The rule of thumb is the same as for subagents: don't add one until you actually need the capability. A server's tool names load at startup and the full schemas stay deferred until a tool is called, so with tool search on by default an idle server costs little. The reason to hold back is focus, not tokens. A long tool list gives the agent more ways to go sideways. ## A decision tree Should every session enforce this? Rule. Only when a specific task comes up? Skill. Does the user trigger it explicitly? Command. Does it need its own context window? Subagent. Does it need to reach outside the repo? MCP server. ::div{.doc-viz dataAnimate="true"} Which building block fits :::div{.dtree} ::::div{.dtree-row style="--i:0"} :::::div{.dtree-q} Should every session enforce it? ::::: :::::div{.dtree-yes} yes → ::::: :::::div{.dtree-result} Rule ::::: :::: ::::div{.dtree-row style="--i:1"} :::::div{.dtree-q} Only when a specific task comes up? ::::: :::::div{.dtree-yes} yes → ::::: :::::div{.dtree-result} Skill ::::: :::: ::::div{.dtree-row style="--i:2"} :::::div{.dtree-q} Does the user trigger it explicitly? ::::: :::::div{.dtree-yes} yes → ::::: :::::div{.dtree-result} Command ::::: :::: ::::div{.dtree-row style="--i:3"} :::::div{.dtree-q} Does it need its own context window? ::::: :::::div{.dtree-yes} yes → ::::: :::::div{.dtree-result} Subagent ::::: :::: ::::div{.dtree-row style="--i:4"} :::::div{.dtree-q} Does it need to reach outside the repo? ::::: :::::div{.dtree-yes} yes → ::::: :::::div{.dtree-result} MCP server ::::: :::: ::: Walk down the rail. Each question that answers no falls through to the next. :: ## One feature, five touch points Take "review this PR for security issues." It splits across all five building blocks. The security rule is always on, so the agent already knows not to log secrets or trust input from the network. The code-review skill loads when the task is a review, bringing the project's reviewing conventions. The `/security-review` command from [Claude](https://claude.com/){rel=""nofollow""} lets me trigger the workflow with a slash. The code-reviewer subagent runs in isolation for a second opinion that won't pollute my main context. And the GitHub MCP server posts the inline comments back on the PR when the review is done. Five building blocks, one feature, no overlap. Each piece earns its keep. ## Two more pieces that don't fit the list These last two work differently from the five above, but you'll run into them. [Hooks](https://docs.claude.com/en/docs/claude-code/hooks){rel=""nofollow""} are shell commands that run on set events, like `SessionStart` or `Stop`. They always run the same way, so they're not advice the agent can ignore. The template runs a `SessionStart` hook that installs dependencies, so the agent never burns a turn on `pnpm install`. [Output styles](https://docs.claude.com/en/docs/claude-code/output-styles){rel=""nofollow""} change how the agent writes, not what it does. The template ships a `house` style with rules like "no em dashes, sentence-case headings, cut filler". It sets the voice for everything the agent says. ## Use the Claude Code plugin You don't need to adopt the whole template to get the agent setup. The `tools/claude/` folder ships as a Claude Code plugin, so you can install the same rules, skills, commands, and code-reviewer subagent in any existing project. Run these in Claude Code: ```bash /plugin marketplace add stijnvanhulle/template /plugin install toolkit@stijnvanhulle ``` The hooks (session-start install, format-on-edit, edit guards) stay in the template's own `.claude/` and don't ship with the plugin, so installing it won't run scripts in your repo. ## Try it The [template](https://github.com/stijnvanhulle/template){rel=""nofollow""} has the building blocks populated with working examples. Clone it, open the folders side by side, and the whole setup stops feeling abstract. There's also a [project page](https://stijnvanhulle.be/projects/template) with a short rundown of what's inside. ## References - [Claude Code overview](https://docs.claude.com/en/docs/claude-code/overview){rel=""nofollow""} - [Memory and `CLAUDE.md`](https://docs.claude.com/en/docs/claude-code/memory){rel=""nofollow""} - [Skills](https://docs.claude.com/en/docs/claude-code/skills){rel=""nofollow""} - [Slash commands](https://docs.claude.com/en/docs/claude-code/slash-commands){rel=""nofollow""} - [Subagents](https://docs.claude.com/en/docs/claude-code/sub-agents){rel=""nofollow""} - [MCP](https://docs.claude.com/en/docs/claude-code/mcp){rel=""nofollow""} - [Hooks](https://docs.claude.com/en/docs/claude-code/hooks){rel=""nofollow""} - [Output styles](https://docs.claude.com/en/docs/claude-code/output-styles){rel=""nofollow""} - [My template repo](https://github.com/stijnvanhulle/template){rel=""nofollow""} # Hello Hi 👋 I'm Stijn, a front-end engineer based in Belgium. I love TypeScript, and I spend most of my time building product front-ends with React, Next.js, Vue and Nuxt. I'm also the creator of Kubb, an open-source code generator for OpenAPI. It started as a small tool to save myself from hand-writing the same typed API clients over and over, and these days it's where most of my open source time goes. Every now and then I also write about front-end work and the tooling I'm currently into. When I'm not coding, you'll usually find me traveling somewhere with a camera in my bag. I love seeing the world, experiencing new cultures, and meeting people along the way. # Kubb ## What it is Kubb is a meta framework for code generation. You define your API once as an OpenAPI or Swagger spec, and Kubb generates the glue code around it: TypeScript types, type-safe clients, data-fetching hooks, validators, and mock data. The core handles parsing, the plugin graph, and file output, so each generator is a plugin you opt into rather than a fixed pipeline. ## Why I built it Hand-written API layers drift from the spec the moment the back-end changes. Treating the spec as the source of truth means a renamed field or a new endpoint shows up as a type error instead of a runtime surprise. The meta-framework approach pushes that idea further: the same spec can drive types, a React Query layer, Zod schemas, and Faker mocks at once, all kept in sync from one config. ## Plugins Kubb ships official plugins for TypeScript types, Axios and fetch clients, TanStack Query for React and Vue, SWR, Zod validation, Faker mock data, and MSW handlers. An MCP plugin exposes the generator to AI agents over the Model Context Protocol. You compose the ones you need in a single `kubb.config.ts`, and the core resolves them into one generation pass. ```bash npx kubb generate ``` See the [documentation](https://www.kubb.dev){rel=""nofollow""} for the full plugin list and config reference. # Template ## What it is A drop-in TypeScript monorepo starter. Fork it, rename a few fields, and you have a repository with build, test, lint, format, release, and CI already wired together, plus a shared setup for AI coding agents. ## Why I built it I kept copying the same config between repos: the linter, the formatter, the test runner, the release flow, the CI workflow. The template collapses that into one fork and keeps the setup consistent across everything I build. ## Tooling [pnpm](https://pnpm.io){rel=""nofollow""} workspaces and [Turborepo](https://turborepo.com){rel=""nofollow""} run the monorepo. [oxlint](https://oxc.rs){rel=""nofollow""} and [oxfmt](https://oxc.rs){rel=""nofollow""} handle linting and formatting, [tsdown](https://tsdown.dev){rel=""nofollow""} builds the packages, and [Vitest](https://vitest.dev){rel=""nofollow""} runs the tests with coverage reported to [Codecov](https://about.codecov.io){rel=""nofollow""}. A [GitHub Actions](https://github.com/features/actions){rel=""nofollow""} workflow runs lint, type-check, and test on every push. Releases go through [Changesets](https://github.com/changesets/changesets){rel=""nofollow""}: add a changeset, and when it lands on `main` a release workflow opens a Version Packages PR that publishes the affected packages to [npm](https://www.npmjs.com){rel=""nofollow""} with provenance. Dependency upgrades run through [taze](https://github.com/antfu-collective/taze){rel=""nofollow""} with a three-day maturity period, so new releases soak before they land. ## Set up for AI agents The template builds on two open formats, `AGENTS.md` and Agent Skills, with symlinks so every tool reads from one source instead of drifting copies. `AGENTS.md` is the canonical instruction file. [Claude Code](https://claude.com/claude-code){rel=""nofollow""}, [Codex](https://github.com/openai/codex){rel=""nofollow""}, [Copilot](https://github.com/features/copilot){rel=""nofollow""}, [Cursor](https://cursor.com){rel=""nofollow""}, [Gemini](https://github.com/google-gemini/gemini-cli){rel=""nofollow""}, and other AGENTS.md runtimes all read from it. The shared toolset lives in `tools/claude/` and doubles as an installable Claude Code plugin: always-on convention rules (`code style`, `JSDoc`, `markdown`, `security`, `testing`, `USA English`), on-demand skills, slash commands like `/changeset` and `/spec`, a `code-reviewer` subagent, output styles, and hooks that install dependencies and format on edit. For larger features, `plans/` holds a spec-driven workflow of spec, research, plan, slices, and verification. ## Use the Claude Code plugin You don't need to adopt the whole template to get the agent setup. The `tools/claude/` folder ships as a Claude Code plugin, so you can install the same rules, skills, commands, and `code-reviewer` subagent in any existing project. Run these in Claude Code: ```bash /plugin marketplace add stijnvanhulle/template /plugin install toolkit@stijnvanhulle ``` The hooks (session-start install, format-on-edit, edit guards) stay in the template's own `.claude/` and don't ship with the plugin, so installing it won't run scripts in your repo. ## Start a new project ```bash gh repo create my-project --template stijnvanhulle/template ``` Fork it, rename the package fields, and start writing code. # Front-end Developer My main task at Aptus was helping out the Energy Lab team in developing tools that our customers could use to track and help out their employees to sport and move more. With over 20 customers it was constantly switching between projects but still maintaining a good generic component library. All of this was written in React and GraphQL. # Full Stack Developer # Front-end Developer As part of the Messagent Development team, I helped out creating the different screens for the different products of BNP and Hello Bank. At that moment all products were written in Jquery and Vanilla Javascript. So when a new project was introduced I volunteered myself to create and try out if it was possible to create that product in React (with Redux and Typescript). It was not possible, for security reasons, to use all libraries that I was used to so we needed to find some workarounds. After a couple of sprints, Company Makers was launched and from that moment it can be used by customers of BNP to create easily your new company online. # Creator Kubb is an open-source meta framework for code generation and the project I spend most of my open source time on. Point it at a Swagger or OpenAPI spec and it generates the TypeScript types, clients, and hooks for you, so your front-end and back-end stop drifting apart. As creator and maintainer I drive the architecture, build and review plugins, write the documentation, and support a growing community of contributors and users. # Front-end Developer As part of the product/hive team, I helped out with the transformation from a +15-year old desktop application to a web-based application. My main role consists of working together with product owners and developers to create the best possible solution based on the needs of our customers. Implement that in an agile way of working and all of this with a good amount of focus on quality (including code reviews, testing, ...). Next to that I also helped together with my lead developer to mentor other front-end developers on a stack that was built with React, Redux, Typescript and an internal component library. All of this was one of the objectives to create a multi-tenant strategy based on a micro-service architecture. # Front-end Lead After a year of being a front-end developer in the product/hive team, I got the chance to become the front-end lead developer. And one of my main responsibilities has been to ensure that our projects are delivered on time and to a high standard of quality. Next to that, I was also responsible to mentor and helping out other team members with the technical implementation of features (and code reviews). To create a really good product I was also in contact with our stakeholders to discuss the future and progress of our products, determine what is important and discuss that further with the team. In addition to managing my team, I have also been responsible for staying up-to-date on the latest front-end technologies and best practices. I have done this by participating in industry events, attending training sessions, and keeping an eye on new trends and innovations in the field. I also took the time to create documentation based on discussions we had and foresee some architectural context around the why's and best practices for the front end. Next to that, I took the opportunity to update our stack (React, Jest, Typescript, Ant-Design, Monorepo structure, generated API clients, Zustand, Zod, ...). This new way of working would benefit the team and would also make sure we can easily scale our products. # Student # Senior Front-end Engineer As a Front-end Developer within the product team at Robovision, my primary focus was the technical evolution and feature-delivery of our product suite using Vue.js. Here I used my previous knowledge to update our frontend architecture with the latest tools (like Nx, Typescript, ...). As an AI company, the focus was also on the integration and use of AI tools. # Front-end Developer As an intern at Smappee, I had the opportunity to gain hands-on experience in a variety of IT-related tasks and projects. My main responsibilities was creating a helpdesk tool (written in Meteor.js) to track the status of the different Smappee devices. Next to that, I helped the mobile team to finish up the app (written with Cordova) used for the Smappee Gas & Water device. The launch of this new product was planned at the end of my internship. In this team, I learned how to use Agile in practice and how to work together with other developers. # Senior Front-end Engineer At Whale I work on the product front-end of an AI platform for SOPs and internal training. Whale is a Ghent-based SaaS company aimed at small and medium teams, where process knowledge tends to live in one person's head until the day they leave.