Skip to content

Advanced TypeScript

In most of the products below, a type error caught at compile time is money or a role caught before it reaches a user: a Mobile Money number that never got validated, a payment state read on the wrong branch, a translation key that does not exist in French. The type system is not decoration on top of the code — it is where a chunk of the domain rules live, checked on every save instead of once in a test.

The snippets on this page are excerpts from repositories I run in production or maintain — VotArena, Jungle, Kuntriz, Akuaba — trimmed but otherwise unedited, each with the file it comes from and the problem it solves. Categories with a single real example say so; nothing here is a rewritten showcase.

2 examples

Generics

Functions and types parameterized over another type, so a wrapper, a container or a repository keeps the exact shape of what it holds instead of widening it to `unknown`.

Instrumenting every use case with one generic wrapper

instrumentContainer<C> adds tracing to every use case in the dependency-injection container in one pass, and returns the exact type C — no caller needs a cast to get its use case back. The type guard was added after a real incident: a plain boolean in the container hit `new Proxy()` and took the API down on 24 September 2026.

export function instrumentUseCase<T extends object>(name: string, instance: T): T {
  return new Proxy(instance, {
    get(target, prop) {
      const value = (target as Record<PropertyKey, unknown>)[prop]
      if (prop !== 'execute' || typeof value !== 'function') {
        return typeof value === 'function' ? value.bind(target) : value
      }
      // …
    },
  })
}

export function instrumentContainer<C extends Record<string, unknown>>(
  container: C,
  exclude: ReadonlySet<string>,
): C {
  const isUseCase = (value: unknown): value is object =>
    typeof value === 'object' &&
    value !== null &&
    typeof (value as { execute?: unknown }).execute === 'function'
  return Object.fromEntries(
    Object.entries(container).map(([key, value]) =>
      exclude.has(key) || !isUseCase(value)
        ? [key, value]
        : [key, instrumentUseCase(value.constructor.name, value)],
    ),
  ) as C
}

SourceVotArenasrc/lib/observability/useCaseObservability.ts

A generic callback that keeps its own return type

runInTransaction<T> runs arbitrary work inside a MongoDB transaction and hands back exactly the type that work resolves to, so wallet credit/debit operations compose inside the same transaction without either side losing its type.

/** Runs `work` inside a Mongo multi-document transaction. */
async runInTransaction<T>(
  work: (session: ClientSession) => Promise<T>,
): Promise<T> {
  const session = await this.connection.startSession();
  try {
    let result: T;
    await session.withTransaction(async () => {
      result = await work(session);
    });
    return result!;
  } finally {
    await session.endSession();
  }
}

SourceJunglesrc/modules/wallet/wallet.service.ts (jungle-api)

1 example

Conditional types

A type that branches on another type, so an invalid combination — a country with no currency reaching a payment field — is rejected before the code runs.

A conditional type that locks a country to its payment scope

The `scope` prop's type depends on the generic country type `C`: a value typed for full payment countries (currency, operators) only accepts `scope="payment"`, so a country with no Mobile Money support can never end up wired into an amount calculation.

export interface PhoneValue<C extends PhoneCountry = CountryConfig> {
  dialCode: string
  localNumber: string
  full: string
  country: C
}

export type InternationalPhoneValue = PhoneValue<PhoneCountry>

interface PhoneInputProps<C extends PhoneCountry> {
  value: PhoneValue<C>
  onChange: (value: PhoneValue<C>) => void
  /**
   * The conditional type LOCKS the scope to the country type carried by the
   * value: a state typed CountryConfig (currency, operators) only accepts
   * scope="payment"; a bare InternationalPhoneValue only accepts
   * scope="international".
   */
  scope?: C extends CountryConfig ? 'payment' : 'international'
  countries?: readonly C[]
}

SourceVotArenasrc/presentation/components/common/PhoneInput.tsx

1 example

Mapped types

A type rebuilt field by field from another type, so a derived shape (an API response after JSON, a lighter client dictionary) stays in lockstep with its source.

Deriving the client-side shape of a server entity

API responses turn every Date into a string once they cross JSON. Serialized<T> derives that shape automatically from the server type, so a hook typed with the server entity would be wrong — this is the type that keeps SWR hooks honest instead.

/**
 * Convertit les Date en string — reflète la sérialisation JSON de l'API.
 * Utilisé pour typer les réponses API dans les hooks SWR.
 */
export type Serialized<T> = {
  [K in keyof T]: T[K] extends Date
    ? string
    : T[K] extends Date | undefined
      ? string | undefined
      : T[K] extends object
        ? Serialized<T[K]>
        : T[K]
}

SourceVotArenasrc/presentation/lib/utils/format.ts

1 example

Infer

Extracting a type from inside a function signature or a wrapper type, instead of writing the same shape a second time by hand.

Reading a use case's success type off its own signature

UseCaseOutput<U> pulls the success value out of a use case's execute() return type with infer, so a route or a hook that consumes a use case does not redeclare its output shape — a field added to the use case's result is visible to the hook at the next compile, with no interface to keep in sync by hand.

/** Valeur portée par le Result en cas de succès de execute(). */
export type UseCaseOutput<U> = U extends {
  execute(...args: never[]): Promise<Result<infer V, unknown>>
}
  ? V
  : never

// src/presentation/hooks/useHomeFeed.ts
type HomeFeedResponse = UseCaseOutput<GetHomeFeedUseCase>

export function useHomeFeed() {
  const { data, error, isLoading } = useSWR<HomeFeedResponse>('/api/feed', fetcher, {
    // …
  })
  return { items: data?.items ?? [], loading: isLoading, error: error?.message ?? null }
}

SourceVotArenasrc/core/application/shared/useCaseTypes.ts + src/presentation/hooks/useHomeFeed.ts

1 example

Template literal types

A string type built from a pattern, so only strings shaped like a given prefix type-check, not any string.

A composite filter value typed by its prefix

The payment-cases filter select combines three possible targets into one query value. ScopeValue accepts only the three known prefixes plus the empty string — a stray string can no longer be assigned to the filter by mistake.

/** Valeur du select « Événement / compétition / artiste » : `event:<id>` … ou ''. */
export type ScopeValue = '' | `event:${string}` | `competition:${string}` | `artist:${string}`

export function scopeValueOf(f: Pick<PaymentCaseFilters, 'eventId' | 'competitionId' | 'artistId'>): ScopeValue {
  if (f.eventId) return `event:${f.eventId}`
  if (f.competitionId) return `competition:${f.competitionId}`
  if (f.artistId) return `artist:${f.artistId}`
  return ''
}

SourceVotArenasrc/presentation/components/payments/paymentCaseFilters.ts

1 example

Discriminated unions

A closed set of states, one object shape per case, where reading a field the current case does not carry is a compile error, not a runtime `undefined`.

Modeling Mobile Money payment polling as a closed union

A payment can be pending, confirmed or refused for one of three distinct reasons; only the failed case carries a message. Reading .message outside that branch is a compile error, so the polling UI never has to guard against a field that is not there.

export type PollPlan =
  | { mode: 'nokash'; url: string }
  | { mode: 'status-url'; url: string }
  | { mode: 'blind' }

export type PollOutcome =
  | { kind: 'pending' }
  | { kind: 'success' }
  | { kind: 'failed'; message: string }

export function interpretPollStatus(status: string | undefined, t: PollT): PollOutcome {
  switch (status) {
    case 'SUCCESS':
    case 'CONFIRMED':
    case 'FREE':
      return { kind: 'success' }
    case 'CANCELED':
      return { kind: 'failed', message: t('canceled') }
    // …
  }
}

SourceVotArenasrc/presentation/lib/paymentPolling.ts

2 examples

Exhaustiveness

A switch that only compiles once every case of a union has been handled, so adding a state and forgetting to handle it breaks the build instead of the app.

A default branch that only compiles while every card type is handled

The home feed mixes six card types. assertNever's parameter type is never once every case above it is handled — adding a seventh kind to the feed without a matching case here fails the build. If a stale client ever sends a kind the union no longer has, it throws with that value named in the message instead of silently returning undefined.

// src/core/domain/shared/assertNever.ts
export function assertNever(value: never, message?: string): never {
  throw new Error(message ?? `Variante non prise en charge : ${describe(value)}`)
}

// src/presentation/views/public/MobileHomeFeedView.tsx
function feedItemKey(item: FeedItem): string {
  switch (item.kind) {
    case 'competition':
      return `c:${item.competition.id}`
    // …
    case 'release':
      return `m:${item.release.id}`
    default:
      return assertNever(item)
  }
}

SourceVotArenasrc/core/domain/shared/assertNever.ts + src/presentation/views/public/MobileHomeFeedView.tsx

No default branch at all — the compiler is the exhaustiveness check

Kuntriz's eligibility engine asks only the questions a given immigration pathway actually needs. QuestionTopic is a 17-member union, and topicAnswered has no default: adding a topic to the union without adding its case here fails to compile, which is what forces every new pathway's questions to stay wired to the engine.

export type QuestionTopic =
  | "household"
  | "spouse"
  | "age"
  // … 14 more topics
  | "ties";

function topicAnswered(ctx: ScoringContext, topic: QuestionTopic): boolean {
  const a = ctx.answers;
  const set = (v: unknown) => v !== undefined && v !== null;
  switch (topic) {
    case "household":
      return true;
    case "spouse":
      return set(a.spouseEducation) || set(a.spouseLanguageLevel);
    // … one case per union member, no default
    case "ties":
      return set(a.tiesToHome);
  }
}

SourceKuntrizlib/eligibility/types.ts + lib/eligibility/engine.ts

1 example

Type guards

A function that narrows an unknown or loosely typed value down to a precise type after checking it at runtime.

Narrowing a locale code down to the languages actually assessed

The wizard reads a language code from client state, but only five of the app's supported languages carry a level assessment. isSupportedLanguage narrows to that subset so an unassessed language cannot be used to index the level map.

export const SUPPORTED_LANGUAGES = ["fr", "en", "de", "it", "nl"] as const;
export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number];

export function isSupportedLanguage(
  lang: LanguageCode,
): lang is SupportedLanguage {
  return (SUPPORTED_LANGUAGES as readonly string[]).includes(lang);
}

/** Niveaux de langue déclarés, indexés par code ISO. */
const LEVEL_FIELDS: Record<SupportedLanguage, keyof WizardState> = {
  fr: "fr",
  en: "en",
  de: "de",
  it: "it",
  nl: "nl",
};

SourceKuntrizlib/assessment/wizard-model.ts

1 example

Assertion functions

A function that does not return a boolean but instead makes the compiler treat its argument as validated for the rest of the block, or throws.

Narrowing a session once, for the rest of the resolver

GraphQL resolvers and Server Actions all start the same way: check the session, then use it. After requireAuth(session), the compiler treats session as Session for the rest of the function — no repeated null check, no non-null assertion.

export function requireAuth(session: Session | null): asserts session is Session {
  if (!session?.user) {
    throw new AuthenticationError()
  }
}

export function requireRole(
  session: Session | null,
  ...roles: UserRole[]
): asserts session is Session {
  requireAuth(session)

  if (!roles.includes(session?.user?.role as UserRole)) {
    throw new ForbiddenError(
      `Rôle requis : ${roles.join(' ou ')}. Vous avez : ${session?.user?.role}`,
    )
  }
}

export function requireAdmin(session: Session | null): asserts session is Session {
  requireRole(session, 'ADMIN')
}

SourceAkuabalib/permissions.ts (akuaba-gestion)

1 example

Branded types

A primitive tagged at the type level so a validated value and a raw one of the same underlying type are no longer interchangeable in the compiler's eyes.

A phone number the payout adapter cannot receive unvalidated

Msisdn is a plain string at runtime, but the compiler only accepts one produced by toMsisdn or isMsisdn. The NOKASH payout function's own output is branded this way, so a raw, unnormalized phone number can no longer reach the payment adapter by accident — no persisted format changes.

declare const MsisdnBrand: unique symbol

export type Msisdn = string & { readonly [MsisdnBrand]: true }

export function isMsisdn(value: unknown): value is Msisdn {
  return typeof value === 'string' && CANONICAL_RE.test(value)
}

// src/infrastructure/payment/routing/cameroonOperator.ts
export interface CameroonMsisdn {
  msisdn: Msisdn
  operator: MobileMoneyOperator
}

export function cameroonMsisdn(phone: string | null | undefined): CameroonMsisdn | null {
  // … normalizes the input and detects the operator …
  const msisdn = `237${national}`
  if (!isMsisdn(msisdn)) return null
  return { msisdn, operator }
}

SourceVotArenasrc/core/domain/shared/Msisdn.ts + src/infrastructure/payment/routing/cameroonOperator.ts

1 example

Satisfies

Checking a literal against a type without widening it, so the literal keeps its exact keys and values available afterward while still being verified.

Locking a hardcoded translation table to its own shape

The root error boundary renders outside next-intl's context, so its two strings are hardcoded on purpose. satisfies checks the object against Record<AppLocale, Record<string, string>> without widening it, so `COPY.fr.title` keeps its literal key instead of becoming a plain `string`.

const COPY = {
  fr: {
    title: 'L’application a rencontré un problème',
    body: 'Ce n’est pas de votre fait : l’incident nous a été signalé. Rechargez la page — la plupart du temps, tout rentre dans l’ordre.',
    reload: 'Recharger',
    home: 'Retour à l’accueil',
  },
  en: {
    title: 'Something went wrong',
    body: 'This is not your fault — the incident has been reported. Reload the page: most of the time, everything comes back.',
    reload: 'Reload',
    home: 'Back to home',
  },
} satisfies Record<AppLocale, Record<string, string>>

SourceVotArenasrc/app/global-error.tsx

1 example

Overloads

Several call signatures for one function, so the argument you pass determines the type you get back.

The scope argument decides the return type

Callers that pass "payment" get a value typed for a country with currency and operators; callers that pass "international" get the lighter type. This is the one function in the codebase with overloads, and it exists so the two scopes stay type-distinct at the call site.

/** Valeur initiale vide pour la portée demandée. */
export function emptyPhoneValue(): PhoneValue<CountryConfig>
export function emptyPhoneValue(scope: 'payment'): PhoneValue<CountryConfig>
export function emptyPhoneValue(scope: 'international'): InternationalPhoneValue
export function emptyPhoneValue(scope: PhoneScope = 'payment'): PhoneValue<PhoneCountry> {
  const country: PhoneCountry =
    scope === 'payment'
      ? DEFAULT_COUNTRY
      : (ALL_COUNTRIES.find((c) => c.iso2 === DEFAULT_COUNTRY.iso2) ?? ALL_COUNTRIES[0])
  return {
    dialCode: country.dialCode,
    localNumber: '',
    full: country.dialCode,
    country,
  }
}

SourceVotArenasrc/presentation/components/common/PhoneInput.tsx

1 example

Module augmentation

Adding fields to a type declared elsewhere — a session object, a translation catalog — so the extension is checked everywhere that type is used.

Translation keys checked by the compiler, not discovered in production

The French catalog is the source of truth for every translation key in the app. Augmenting next-intl's own IntlMessages interface with it means calling t('unknownKey') anywhere in the app is a TypeScript error, not a blank string a user finds first.

import type { Messages as AppMessages } from './loadMessages'

declare global {
  interface IntlMessages extends AppMessages {}
}

export {}

// loadMessages.ts
export function loadMessages(locale: AppLocale) {
  return catalogs[locale]
}

export type Messages = (typeof catalogs)['fr']

SourceVotArenasrc/i18n/global.d.ts + src/i18n/loadMessages.ts

2 examples

Utility types

Deriving a type from a value or another type — a function's return type, the members of a `const` array — instead of maintaining a duplicate declaration.

Typing a translator without a React hook in a pure module

Payment-polling logic is deliberately framework-free so it can be unit-tested without mounting a component; it still needs the exact shape of a next-intl translator. ReturnType<typeof useTranslations<'…'>> derives that type from the real hook instead of hand-writing a matching interface that could drift from it.

import type { useTranslations } from 'next-intl'

/** Traducteur de l'espace `hooks.paymentPolling`. */
export type PollT = ReturnType<typeof useTranslations<'hooks.paymentPolling'>>

export function interpretPollStatus(status: string | undefined, t: PollT): PollOutcome {
  // …
}

SourceVotArenasrc/presentation/lib/paymentPolling.ts

Required environment variables as a typed, spreadable list

A missing production secret used to fall back to a development default, so a misconfiguration failed silently instead of on boot. The two `as const` tuples are typed literal lists that get spread together and walked once, so the required set for production is data, not a chain of if-statements.

const REQUIRED_ALWAYS = ['MONGODB_URI'] as const

const REQUIRED_IN_PRODUCTION = [
  'JWT_ACCESS_SECRET',
  'PAYMENT_WEBHOOK_BASE_URL',
] as const

export function validateEnv(raw: Record<string, unknown>): Record<string, unknown> {
  const isProduction = raw.NODE_ENV === 'production'
  const errors: string[] = []
  const value = (key: string): string => String(raw[key] ?? '').trim()

  const required = [
    ...REQUIRED_ALWAYS,
    ...(isProduction ? REQUIRED_IN_PRODUCTION : []),
  ]
  for (const key of required) {
    if (!value(key)) errors.push(`${key} is required`)
  }
  // …
  return raw
}

SourceJunglesrc/common/config/env.validation.ts (jungle-api)

1 example

Abstract generics

A base class parameterized by the identity type of its subclasses, so two unrelated entities can never be compared to each other by mistake.

An identity type that keeps unrelated entities apart

Entity<TId extends DomainId> is the base of every aggregate in the domain layer. Because TId is a type parameter, Entity<CompetitionId> and Entity<EventId> are different types to the compiler — comparing a competition to an event by id is rejected before the code runs, not caught in a test.

// DomainId.ts
export abstract class DomainId {
  protected constructor(readonly value: string) {
    if (!value || value.trim().length === 0) {
      throw new Error(`${this.constructor.name} cannot be empty`)
    }
  }

  equals(other: DomainId): boolean {
    return this.value === other.value
  }
}

// Entity.ts
export abstract class Entity<TId extends DomainId> {
  protected constructor(readonly id: TId) {}

  equals(other: Entity<TId>): boolean {
    return this.id.equals(other.id)
  }
}

SourceVotArenasrc/core/domain/shared/DomainId.ts + Entity.ts

Practices

Strict mode, everywhere
TypeScript strict mode is on in every project shown on this page — VotArena, Jungle, Kuntriz, Akuaba — no project-wide any escape hatch.
6,087 automated tests
VotArena's test suite — 6,087 automated tests — ran green on 25 September 2026. Types catch shape mistakes at compile time; this suite is what catches the rest.
Lint and format on every commit
ESLint and Prettier run through a Husky pre-commit hook (lint-staged) on VotArena, so a file that fails either never reaches a branch.
One exhaustiveness helper, used at real boundaries
assertNever and the branded Msisdn type shown above are not demo code written for this page — they compile against VotArena's actual home feed, payment-callback dispatch and NOKASH payout path.

Need this level of rigour on your product?

Tell me about your product and the constraints you are working under.