A Swedish will has almost no formal requirements. It must be in writing, the testator must sign it (or acknowledge an earlier signature) while two witnesses are present at the same time, and the witnesses sign it. There is no notary and no registry. That simplicity is exactly what makes it fragile: if one witness was not allowed to witness, the will, or part of it, can be void, and nobody finds out until the person is dead and the heirs start reading the document carefully.
I spent the last few months building a form engine that turns questions into Swedish legal documents (wills, cohabitation agreements, gift deeds) as PDF. Most of the interesting work was not the legal text. It was making sure the software could not produce a document that looks fine and is quietly invalid. These are the four bugs I designed out, with the code that does it.
1. Accepting a witness the law disqualifies
The rules are in chapter 10, section 4 of the Swedish Inheritance Code (10 kap. 4 § ärvdabalken). For a developer who does not read Swedish, they come in two groups.
Nobody in this group may witness the will at all:
- anyone under 15
- anyone who, because of a mental disorder, cannot understand what witnessing means
- the testator's spouse or cohabiting partner (sambo)
- the testator's relatives in a straight line up or down (parents, grandparents, children, grandchildren)
- the testator's siblings
- the testator's in-laws (svågerlag)
In the second group, the bar is narrower: nobody may witness a provision that benefits
- themselves
- their own spouse, sambo, sibling, straight-line relative or in-law
- a person they act for as guardian, administrator, trustee or holder of a lasting power of attorney
- a company, association, religious community or foundation where they sit on the board
One explicit exception: being named executor does not disqualify you.
The legal effect differs between the groups. The first group makes the witnessing defective. The second group is about the specific provision. In practice that distinction does not matter for a product. A user who wanted to leave the summer house to a niece does not want a will where that gift is void because the niece's husband signed as a witness. So the engine treats every one of these as a hard error that blocks checkout.
The data model is a single list of options the user picks from for each witness. Everything except none is a disqualification:
type Option = { value: string; label: string }; export const WITNESS_RELATIONS: Option[] = [ { value: 'none', label: 'No relation listed below' }, { value: 'partner', label: "Testator's spouse or sambo" }, { value: 'lineal', label: 'Child, grandchild, parent, grandparent or other straight-line relative' }, { value: 'sibling', label: 'Sibling' }, { value: 'inlaw', label: 'Brother-, sister-, parent-, son- or daughter-in-law' }, { value: 'receiver', label: 'Receives something under the will, or is close family of someone who does' }, { value: 'board', label: 'Board member of a company or organisation that receives something' }, { value: 'under15', label: 'Under 15' }, { value: 'guardian', label: 'Guardian, administrator or attorney-in-fact for someone involved' }, { value: 'capacity', label: 'Cannot understand what being a witness means' }, ]; (The real labels are in Swedish. I translated them here.)
Asking the user to classify the witness is the main check, but people pick "none" without reading. So the validator also compares names against everything else in the answers: the testator, the partner, and every person who receives something.
function same(x: string, y: string): boolean { const norm = (s: string) => s.trim().toLowerCase().replace(/\s+/g, ' '); return !!x && !!y && norm(x) === norm(y); } // inside validate(answers) const testators = [str(a, 't1_name')]; const receivers = [ ...items(a, 'bequests').map((it) => str(it, 'name')), ...items(a, 'shares').map((it) => str(it, 'name')), str(a, 'partner_name'), ]; for (const i of [1, 2]) { const name = str(a, `w${i}_name`); if (!name) continue; // naming witnesses up front is optional const rel = str(a, `w${i}_relation`); if (rel && rel !== 'none') { issues.push({ level: 'error', field: `w${i}_relation`, message: `${name} cannot be a witness. ${WITNESS_ERRORS[rel]}` }); } else if (testators.some((t) => same(t, name))) { issues.push({ level: 'error', field: `w${i}_name`, message: 'The person making the will cannot witness it.' }); } else if (receivers.some((r) => same(r, name))) { issues.push({ level: 'error', field: `w${i}_name`, message: `${name} receives something under the will and cannot be a witness.` }); } } if (same(str(a, 'w1_name'), str(a, 'w2_name'))) { issues.push({ level: 'error', field: 'w2_name', message: 'You need two different witnesses.' }); } Two design notes. First, the name match is deliberately dumb: case and whitespace only, no fuzzy matching. A fuzzy match that flags "Anna Berg" against "Anna Bergström" produces false errors that teach users to ignore errors. Second, this check cannot be complete. The software cannot know that the witness is the beneficiary's brother-in-law. That is why the printed document ends with an appendix page that lists the disqualified groups as a checklist the testator reads before the signing. The validator catches the mistakes it can see, and the paper covers the rest.
2. Getting the century wrong in a personnummer
Every party in these documents is identified by a Swedish personal identity number, the personnummer. It looks simple and has four traps.
- Two lengths. People write
YYMMDD-NNNN(10 digits) orYYYYMMDD-NNNN(12 digits). Store one canonical form. - The check digit covers 10 digits only. It is a Luhn (mod 10) checksum over
YYMMDDNNNplus the check digit. The century is not part of it. - The separator carries information. In the 10-digit form,
-means the person is under 100, and+means they are 100 or older.121212-1212is born in 2012,121212+1212in 1912. - Coordination numbers. People who are not registered as residents get a samordningsnummer, which is the same format with 60 added to the day. Born on the 14th becomes
74. Your date validation rejects it unless you know.
Here is the normaliser. It returns YYYYMMDD-NNNN or null.
export function normalisePnr(raw: string, today = new Date()): string | null { const s = raw.replace(/\s/g, ''); const m = /^(\d{2})?(\d{2})(\d{2})(\d{2})([-+]?)(\d{4})$/.exec(s); if (!m) return null; let [, cent, yy, mm, dd, sep, last] = m; // Coordination numbers add 60 to the day. const day = Number(dd) > 60 ? Number(dd) - 60 : Number(dd); if (!cent) { // Pick the most recent birth date that is not in the future, // then go back 100 years if the separator is '+'. let full = 2000 + Number(yy); if (Date.UTC(full, Number(mm) - 1, day) > today.getTime()) full -= 100; if (sep === '+') full -= 100; cent = String(full).slice(0, 2); } // Luhn over the 10 digits YYMMDDNNNC: double every other digit from the left. const ten = `${yy}${mm}${dd}${last}`; let sum = 0; for (let i = 0; i < 10; i++) { let d = Number(ten[i]) * (i % 2 === 0 ? 2 : 1); if (d > 9) d -= 9; sum += d; } if (sum % 10 !== 0) return null; // Reject 31 February and friends. const date = new Date(Date.UTC(Number(cent + yy), Number(mm) - 1, day)); if (date.getUTCMonth() !== Number(mm) - 1 || date.getUTCDate() !== day) return null; return `${cent}${yy}${mm}${dd}-${last}`; } My first version compared only the year: if (2000 + yy > currentYear) full -= 100. That is wrong for one narrow group. Someone born in December 1926, writing their number with - in October 2026, is 99. The year check puts them in 2026, which is a future birth date, which makes their age negative. For a will, that matters: the age check (you must be 18 to make a will) then blocks a 99-year-old from writing one. Comparing the full date, as above, fixes it. It is the kind of bug no test catches unless you write the test on purpose, so write that test.
Note that the normalised output keeps the original day, +60 included. The personnummer is an identifier, not a date, and rewriting the day would produce someone else's number. Anything that needs the birth date strips the offset itself:
/** Age in whole years on `onIso` (YYYY-MM-DD) for a normalised personnummer. */ export function ageFromPnr(pnr: string, onIso: string): number | null { const m = /^(\d{4})(\d{2})(\d{2})-/.exec(pnr); if (!m) return null; const day = Number(m[3]) > 60 ? Number(m[3]) - 60 : Number(m[3]); const [y, mo, d] = onIso.split('-').map(Number); let age = y - Number(m[1]); if (mo < Number(m[2]) || (mo === Number(m[2]) && d < day)) age--; return age; } This is plain calendar arithmetic, with no Date and no time zones, which is what you want for "has this person turned 18 today".
If you need the official description of the format, Skatteverket's page on personnummer is the source.
3. Trusting answers to fields the user can no longer see
A will form has many branches. Are you married, a sambo or single? Do you have children? Do you have children from an earlier relationship? Do you want to leave specific items to specific people? Each answer shows or hides later questions.
The conditions are data, not code, so the client and the server read the same definition:
export type Cond = | { field: string; equals: string } | { field: string; in: string[] } | { field: string; notEmpty: true } | { field: string; isEmpty: true } | { all: Cond[] } | { any: Cond[] } | { not: Cond }; // A field that only appears for married or cohabiting testators: { id: 'partner_name', type: 'text', label: 'Partner name', showIf: { all: [ { field: 'will_kind', equals: 'individual' }, { field: 'civil_status', in: ['married', 'sambo'] } ] } } The evaluator is a dozen lines:
export function test(c: Cond | undefined, s: Scope): boolean { if (!c) return true; if ('all' in c) return c.all.every((x) => test(x, s)); if ('any' in c) return c.any.some((x) => test(x, s)); if ('not' in c) return !test(c.not, s); const v = lookup(c.field, s); const sv = typeof v === 'string' ? v : ''; if ('equals' in c) return sv === c.equals; if ('in' in c) return c.in.includes(sv); if ('notEmpty' in c) return Array.isArray(v) ? v.length > 0 : sv.trim() !== ''; if ('isEmpty' in c) return Array.isArray(v) ? v.length === 0 : sv.trim() === ''; return true; } Here is the bug that sounds trivial and is not. The user says they are married, enters their spouse's name and picks "my spouse gets everything". Then they change their mind, go back and pick "single". The partner fields disappear from the screen. But the client still has partner_name and main_heir: 'partner' in its state, and it posts them.
If the server validates and builds from the raw request, the document now contains a clause leaving everything to a spouse the user has said does not exist. Worse, the witness check above would compare witnesses against that phantom spouse. And separately from honest stale state, anyone can post a crafted request with fields the form never asked for.
So the first thing the server does with any request is prune it: keep only answers to fields that are visible given the other answers, clean each value by its field type, and drop everything else.
export function prune(def: DocumentDef, raw: Record<string, unknown>): Answers { const out: Answers = {}; // Two passes, so a condition can depend on a field later in the form. for (let pass = 0; pass < 2; pass++) { for (const step of def.steps) { if (!test(step.showIf, { top: out })) { for (const f of step.fields) delete out[f.id]; continue; } for (const f of step.fields) { if (f.type === 'info') continue; const visible = pass === 0 || test(f.showIf, { top: out }); if (!visible) { delete out[f.id]; continue; } out[f.id] = f.type === 'repeater' ? cleanItems(f, raw[f.id], out) // same idea, one level down : cleanValue(f, raw[f.id]); } } } return out; } Three properties make this safe:
- The output is built from the definition, not from the input. It iterates over
def.steps, so a key that is not a field can never reachout. There is no denylist to forget to update. - Every value goes through its type.
cleanValuecaps length, collapses whitespace, turns checkbox values into exactly'ja'or'', strips non-digits from money fields and runsnormalisePnron personnummer fields. - The pipeline order is fixed. Every API route calls one function:
export function evaluate(def: DocumentDef, raw: Record<string, unknown>) { const answers = prune(def, raw); const schemaIssues = validateSchema(def, answers); // Legal checks only run once the basics hold, so document authors can rely on them. const legal = schemaIssues.some((i) => i.levelSource:https://dev.to/stackedboost/swedish-will-witness-rules-in-typescript-4-bugs-a-form-engine-must-prevent-pcjRelated posts
