DEV Community

Peter Hallander
Peter Hallander

Posted on

Swedish will-witness rules in TypeScript: 4 bugs a form engine must prevent

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' },
];
Enter fullscreen mode Exit fullscreen mode

(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.' });
}
Enter fullscreen mode Exit fullscreen mode

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.

  1. Two lengths. People write YYMMDD-NNNN (10 digits) or YYYYMMDD-NNNN (12 digits). Store one canonical form.
  2. The check digit covers 10 digits only. It is a Luhn (mod 10) checksum over YYMMDDNNN plus the check digit. The century is not part of it.
  3. The separator carries information. In the 10-digit form, - means the person is under 100, and + means they are 100 or older. 121212-1212 is born in 2012, 121212+1212 in 1912.
  4. 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}`;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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'] } ] } }
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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 reach out. There is no denylist to forget to update.
  • Every value goes through its type. cleanValue caps length, collapses whitespace, turns checkbox values into exactly 'ja' or '', strips non-digits from money fields and runs normalisePnr on 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.level === 'error') ? [] : def.validate(answers);
  const issues = [...schemaIssues, ...legal];
  return { answers, issues, ok: !issues.some((i) => i.level === 'error') };
}
Enter fullscreen mode Exit fullscreen mode

The document's own validate() and build() only ever receive pruned answers. That contract is written in the type definition, and it is why the witness check can trust that partner_name means a partner exists.

4. A signature page that can be swapped

The witnesses sign an attestation, a short paragraph saying the testator signed in their simultaneous presence, of sound mind and free will. Their signatures prove that the testator signed this document.

Now consider a PDF layout where the testator's signature lands at the bottom of page 3 and the witness box flows to the top of page 4. Page 4 is now a sheet with a generic attestation and two witness signatures on it. Nothing on it ties it to the will on pages 1 to 3. Someone could print a different page 1 to 3 and staple the original page 4 to it.

So the renderer keeps the testator's signature block and the witness block on one page. I use pdf-lib, which gives you raw drawing primitives and no layout engine, so "keep together" is something you write yourself. The renderer tracks a cursor y and starts a new page when a block does not fit:

function ensure(ctx: Ctx, needed: number) {
  if (ctx.y - needed < MARGIN_BOTTOM) newPage(ctx);
}
Enter fullscreen mode Exit fullscreen mode

Before drawing a signature block that is directly followed by a witness block, it measures both and asks for the combined height:

model.blocks.forEach((b, i) => {
  const next = model.blocks[i + 1];
  if (b.t === 'signatures' && next?.t === 'witnesses') {
    const sigH = (b.intro ? 40 : 0) + (b.placeDate !== false ? 40 : 0)
      + Math.ceil(b.signers.length / 2) * 84 + 10;
    const witTextLines = wrapRich(next.text, 10, CONTENT_W - 28, fonts, true).length;
    const witH = 26 + witTextLines * 14.5 + (next.lines?.length ?? 5) * 26 + 30;
    // Only force a break if the pair fits on an empty page at all.
    if (sigH + witH < PAGE_H - MARGIN_TOP - MARGIN_BOTTOM) ensure(ctx, sigH + witH);
  }
  block(ctx, b);
});
Enter fullscreen mode Exit fullscreen mode

The witness text is wrapped with the same function that draws it, so the measured height is the drawn height. The guard on the last line matters: if the pair could never fit on one page, forcing a break would just produce an empty page and change nothing.

Two cheaper measures back this up. Every page of the document except the last gets an initials line in the footer, so the testator can initial each page. And the footer prints "page N of M" with the total, so a removed or added page is visible.

Fonts: å, ä and ö

The standard 14 PDF fonts that pdf-lib can use without embedding are encoded in WinAnsi. That covers å, ä and ö, so Swedish text appears to work, until a name contains a character outside WinAnsi and the PDF build throws. Names come from users, so that happens. Embedding real TrueType fonts through fontkit removes the class of problem:

import { PDFDocument } from 'pdf-lib';
import fontkit from '@pdf-lib/fontkit';

const doc = await PDFDocument.create();
doc.registerFontkit(fontkit);
const serif = await doc.embedFont(await loadFontBytes('Spectral-Regular'), { subset: true });
Enter fullscreen mode Exit fullscreen mode

subset: true keeps only the glyphs actually used, so four embedded fonts still produce a small file. Two things that bit me: load the TTF bytes from a path that exists both in development and in the built output (I check a short list of candidate directories and cache the bytes), and set doc.setLanguage('sv-SE') so screen readers pronounce the text correctly.

Bonus: a preview you cannot copy

Before paying, the user sees the first part of the document as HTML, and the rest is blurred. A CSS blur is not protection: the text is in the DOM, and "view source" defeats it. So the server scrambles the hidden part before it sends it. The scrambler keeps word lengths, punctuation, case and digits-as-digits, so the blurred block has the same shape as the real one, but the letters come from a small deterministic generator:

function scramble(s: string): string {
  let n = 7;
  return s.replace(/[A-Za-zÅÄÖåäöÉé0-9]/g, (c) => {
    n = (n * 31 + 11) % 26;
    const ch = String.fromCharCode(97 + n);
    return /[A-ZÅÄÖÉ]/.test(c) ? ch.toUpperCase() : /\d/.test(c) ? String(n % 10) : ch;
  });
}
Enter fullscreen mode Exit fullscreen mode

It is not cryptography and does not need to be. The real text never leaves the server until the order is paid.

What generalises

None of this is specific to wills. Any form that produces a document with legal weight has the same four failure modes: a rule the user has to apply to their own situation, an identifier with hidden structure, branching state that leaks between branches, and a layout that can separate the proof from the thing it proves. The fixes are also the same: encode the rule as data and check what you can, normalise identifiers at the edge, rebuild the input from the schema on the server, and measure before you paginate.

I use this engine in Vittnet, a Swedish service for wills, cohabitation agreements and gift deeds.

Top comments (0)