Building a Swedish Casino Comparison Table in TypeScript: Types for Licences, Payouts and Payment Rails
Most comparison tables rot the same way. Someone pastes a row from a spreadsheet, a payout time arrives as the string "instant-ish", a licence number goes missing, and the page ships anyway. Nobody notices until a reader does.
This is a data-shaped problem, and TypeScript is good at data-shaped problems. In this walkthrough we'll model a ranking of Swedish online casinos so that malformed rows fail at compile time instead of in production. Along the way we'll use discriminated unions, mapped types and conditional types on something more interesting than a todo list.
No prior gambling knowledge needed. All you need is the type system and a willingness to be strict.
Start with the record, not the table
The first instinct is to design the table: columns, sorting, filters. Resist it. Design the record first, because the table is just a view over an array of them.
Here's a naive version most of us would write on a Friday afternoon:
interface Casino {
name: string;
licence: string;
payoutHours: number;
methods: string[];
score: number;
}
It compiles. It also permits licence: "", payoutHours: -4, methods: ["swissh"] and score: 11. Every one of those is a bug waiting for a reader.
The fix is to make illegal states unrepresentable. A Swedish licence is not a free-text field. It has a regulator, a status and a date, and a lapsed licence should behave differently from an active one. That's a job for a discriminated union:
type Licence = | {
status: "active";
regulator: "Spelinspektionen";
grantedOn: string
} | {
status: "suspended";
regulator: "Spelinspektionen";
since: string; reason: string
} | {
status: "unlicensed"
};
Now status is the tag, and each variant carries only the fields that make sense for it. An unlicensed entry has no grantedOn to forget. If you want the mechanics of how the compiler tells these apart, the type narrowing guide walks through control flow analysis on exactly this pattern.
Anyone building a structured casino comparison table needs a clear picture of how operators are evaluated in practice. Swedish casino rankings typically bring together information about licensing, game selection, payment methods, bonuses and withdrawal conditions, making them useful examples of structured data. A practical reference is this list, which presents a ranking of online casinos for players in Sweden. Looking at how casino reviews organise operator details, payout information and payment options can help developers identify which fields belong in their TypeScript models and which should remain optional. The result is a more consistent comparison table that readers can navigate and developers can maintain.
One caution before we model it. Real rankings change. A site that was fastest last quarter might not be this quarter, so every record needs a checkedOn date. A table with no freshness field is a table you can't trust.
Payment rails are a union too
Sweden is a good test case because its payment landscape is distinctive. Swish dominates person-to-person and merchant payments, Trustly handles bank-to-bank deposits through open banking, and cards still exist but play a smaller role. These aren't interchangeable strings. They have different properties.
Swish settlement leans on the central bank's RIX payment system, which gives instant settlement around the clock. A card payment has no such guarantee. Bank transfers depend on the bank. So we model each rail as its own variant:
type PaymentRail = | {
kind: "swish";
instant: true } | {
kind: "trustly";
instant: true;
requiresBankId: boolean } | {
kind: "card";
network: "visa" | "mastercard";
instant: boolean } | {
kind: "bank-transfer";
typicalDays: number };
Notice the asymmetry. swish is always instant, so instant is the literal type true, not boolean. You can't construct a Swish rail claiming otherwise. A card carries a network, a bank transfer carries typicalDays, and nothing carries fields it doesn't own.
This is where conditional types earn their keep. Suppose you want a helper that extracts only the rails that are instant, so a "fast deposits" filter can't accidentally include a bank transfer:
type InstantRail = Extract<PaymentRail, { instant: true }>;
Extract is a conditional type under the hood, and it distributes over the union. The result keeps swish and trustly, and drops anything whose instant can't be true. Change a variant later and every filter built on InstantRail updates with it. No grep required.
Some fintech reporting has covered how Nordic open banking flows built on Trustly and BankID changed the sign-up experience, which is the real-world reason requiresBankId is worth a field of its own.
Scores with mapped types
Review tables usually carry category scores: bonuses, game selection, support, payouts. Writing those fields by hand invites drift. Add a category in one place, forget it in another, and half your records silently lack it.
Mapped types fix this by deriving the shape from a single source of truth:
const categories = ["bonuses", "games", "support", "payouts"] as const;
type Category = (typeof categories)[number];
type Score = 1 | 2 | 3 | 4 | 5;
type Scores = { [K in Category]: Score };
Add "mobile" to the array and Scores now requires it on every record. The compiler flags every row that hasn't caught up. The mapped types guide covers key remapping and modifiers if you want to go further, say, making scores optional for a partial import with { [K in Category]?: Score }.
The Score union deserves a note. Typing it as number would let 11 through. Typing it as 1 | 2 | 3 | 4 | 5 makes 11 a compile error. It's a small thing, and it's exactly the kind of small thing that stops a rogue 11 from showing up in a published table.
Putting the record together
Now the full entry, with a generic wrapper so the same machinery can serve other markets later:
interface Ranked<TLicence extends Licence = Licence> {
rank: number;
name: string;
licence: TLicence;
payoutHours: number;
rails: readonly PaymentRail[];
scores: Scores;
checkedOn: string;
}
The generic parameter lets you write a function that only accepts operators with an active licence, which is the sensible default for anything you'd publish as a recommendation:
type ActiveCasino = Ranked<Extract<Licence, { status: "active" }>>;
function publishable(rows: Ranked[]): ActiveCasino[] { return rows.filter( (r): r is ActiveCasino => r.licence.status === "active" ); }
The type predicate in the filter callback is doing real work. Past that line, the compiler knows every row is active, so you can read grantedOn without a check. A discriminated union plus a predicate gives you exhaustive narrowing without a single cast, a point the Felt engineering team makes well in their write-up on type predicates and tagged unions.
Where the compiler stops and tests begin
Types catch shape errors. They can't catch a licence that lapsed last Tuesday or a payout claim that a site quietly stopped honouring. That's a data-freshness problem, and no generic will solve it.
Swedish licensing in particular moves. Licence categories, fees and the broader regulatory approach get revisited, and legal commentary on the Swedish gaming law shows proposals shifting how obligations are framed. Whatever you build, version your licence variant and stamp each record. If the rules change, you want a type error pointing at every record that assumed the old ones.
My honest take: the temptation is to over-model. You can spend a weekend building a type-level state machine for a table that has fifteen rows. Don't. Model the fields where wrong data is expensive (licence status, payout claims, payment rails) and leave the rest as plain strings until they hurt.
A quick build checklist
- Define the record first and the table second.
- Use a discriminated union anywhere a field's meaning depends on a status.
- Derive repeated shapes with mapped types from one
as constarray. - Use literal unions (
1 | 2 | 3 | 4 | 5) instead ofnumberfor bounded values. - Add a
checkedOnfield and treat stale rows as a bug. - Write type predicates so narrowing happens once, not in every consumer.
FAQ
Why use discriminated unions instead of optional fields?
Optional fields let invalid combinations through, like an unlicensed operator with a grant date. A discriminated union ties each field to a tag, so the compiler rejects impossible states and narrows automatically when you check the tag.
Can mapped types keep category scores consistent?
Yes. Derive a Scores type from a single as-const array of category names. Adding a category in one place forces every record to supply it, and the compiler flags rows that haven't been updated yet.
Does TypeScript validate data at runtime?
No. Types vanish after compilation, so external JSON still needs runtime validation with a schema library. Types protect the code you control; validation protects the boundary where untrusted data enters your program.
How do I filter a union down to one variant?
Use the built-in Extract utility, for example Extract<PaymentRail, { instant: true }>. It's a distributive conditional type that keeps only matching variants, and it updates automatically whenever you change the union.
Closing thought
A comparison table looks like a UI problem and behaves like a data-integrity problem. Get the types right and the rest is rendering. Start with one record, make its bad states impossible to write, and let the compiler do the nagging so your readers never have to.
Comments
Loading comments…