Bezpieczne programowanie w TypeScript: jak wykorzystać system typów do ograniczania ryzyka

0
30
Rate this post

Nawigacja:

Dlaczego bezpieczeństwo w TypeScript zaczyna się od typów

Jakie błędy TypeScript wyłapuje, a jakich nie dotyka

TypeScript chroni głównie przed błędami struktury danych i kontraktów funkcji, a nie przed atakami sieciowymi czy SQL injection. To narzędzie do ograniczania klasy błędów „programistycznych”, nie „hakerskich”.

TypeScript potrafi wykryć między innymi:

  • Wywołanie funkcji z nieprawidłowym typem argumentu.
  • Użycie nieistniejącej właściwości obiektu.
  • Brak obsługi części przypadków w unii typów.
  • Przypisanie wartości, która nie pasuje do kontraktu (np. `string` do pola oczekującego `number`).
  • Potencjalne odwołania do `null` / `undefined` (przy włączonym `strictNullChecks`).

Nie zabezpiecza natomiast przed:

  • XSS, CSRF, SQL injection – to obszar dla walidacji, sanitizacji i architektury, nie systemu typów.
  • Błędną logiką biznesową (np. zły wzór, pomylony warunek).
  • Zachowaniami zależnymi od środowiska (timeouty, sieć, wyścigi asynchroniczne).

Bezpieczne programowanie w TypeScript to świadome wykorzystanie typów do zamykania tych klas błędów, które kompilator jest w stanie realnie złapać, i do wyraźnego oddzielenia miejsc, gdzie trzeba dodać walidację runtime.

Mniej bugów vs realnie bezpieczniejsza aplikacja

Mniej błędów typów nie zawsze oznacza bezpieczniejszą aplikację. Różnica jest subtelna, ale kluczowa.

„Mniej bugów” to sytuacja, w której rzadziej widzisz w logach komunikaty typu „Cannot read property 'x’ of undefined”. Prosty zysk: mniej awarii produkcji spowodowanych głupimi literówkami i złym użyciem API.

„Bezpieczniejsza aplikacja” to coś więcej. To m.in.:

  • Brak „stanów niemożliwych” – nie można skonstruować obiektu w kombinacji pól, która w logice biznesowej jest zabroniona.
  • Rozdzielenie danych z zewnątrz (niezaufanych) od danych zweryfikowanych, z odzwierciedleniem tego podziału w typach.
  • Kontrakty typów wymuszające pełne pokrycie scenariuszy, bez cichych „domyślnych” ścieżek.
  • Typy domenowe, które utrudniają przypadkowe pomylenie np. ID użytkownika z ID zamówienia.

System typów staje się wtedy elementem architektury bezpieczeństwa. Nie jest „ładnym dodatkiem”, tylko pierwszą linią obrony przed całymi rodzinami błędów.

Typy jako dokumentacja, kontrakt i siatka ochronna

Dobrze zaprojektowane typy pełnią jednocześnie rolę dokumentacji, kontraktu i „siatki ochronnej” przy refaktoryzacji.

Przykład prostego kontraktu w TypeScript:

type UserId = string;

interface User {
  id: UserId;
  email: string;
  isActive: boolean;
}

function deactivateUser(user: User): User {
  return { ...user, isActive: false };
}

Tu kontrakt jest jasny:

  • Funkcja przyjmuje pełnego `User`a, a nie „jakikolwiek obiekt z id”.
  • Zwraca nowego `User`a (niemutująco), co jest natychmiast widoczne z typu.

Przy refaktoryzacji (np. zmiana nazwy pola, dodanie nowego obowiązkowego pola) kompilator natychmiast pokaże wszystkie miejsca, które wymagają dostosowania. Typy działają więc jako siatka ochronna, która pozwala odważniej wprowadzać zmiany.

Refaktoryzacja bez strachu dzięki silnym typom

Wyobraź sobie, że zmieniasz sposób reprezentacji statusu zamówienia z „magicznych stringów” na typ unii. W kodzie bez typów taka zmiana to ryzyko, że pominiesz jakieś miejsce, w którym ktoś wpisał `if (status === „ok”)`.

W TypeScript z poprawnie zdefiniowanymi typami, zmiana statusu z `string` na:

type OrderStatus = "new" | "paid" | "shipped" | "cancelled";

spowoduje lawinę błędów kompilacji tam, gdzie typy nie pasują. To nie problem, to ogromna korzyść. Każdy błąd kompilatora wskazuje miejsce, które trzeba przejrzeć i dopasować do nowego modelu. Ryzyko „cichego pominięcia” fragmentu kodu spada drastycznie.

Konfiguracja kompilatora jako fundament: strict, noImplicitAny i spółka

Minimalny zestaw opcji zwiększających bezpieczeństwo

Bez sensownej konfiguracji `tsconfig.json` TypeScript jest wyraźnie mniej użyteczny pod kątem bezpieczeństwa. Tryb domyślny bywa zbyt pobłażliwy.

Minimalny zestaw opcji, które realnie robią różnicę:

  • „strict”: true
  • „noImplicitAny”: true (włączane przez strict)
  • „strictNullChecks”: true (włączane przez strict)
  • „noImplicitThis”: true (włączane przez strict)
  • „strictPropertyInitialization”: true (włączane przez strict)

W praktyce najprościej jest ustawić:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  }
}

Flaga "strict": true włącza pakiet kilku opcji. Jeśli w projekcie jest dużo starego kodu, można na początek włączyć wybrane z nich ręcznie i stopniowo dochodzić do pełnego strict.

Co konkretnie dają kluczowe opcje

noImplicitAny blokuje niejawne wnioskowanie typu `any`. Przykład:

function add(a, b) {
  return a + b;
}

Bez `noImplicitAny` kompilator uzna `a` i `b` za `any` i nie zgłosi błędu. Z tą opcją dostajesz komunikat, że typy parametrów muszą być jawnie określone lub da się je wywnioskować. Zmusza to do deklarowania intencji:

function add(a: number, b: number): number {
  return a + b;
}

strictNullChecks powoduje, że `null` i `undefined` przestają być traktowane jak „pasujące do wszystkiego”. Jeśli typ to `string`, to nie można w niego wstrzyknąć `null` bez jawnego rozszerzenia typu do `string | null`.

Przykład:

let name: string;
name = null; // błąd przy strictNullChecks

let maybeName: string | null = null; // poprawne

noImplicitThis uniemożliwia użycie nieztypowanego `this` w funkcjach, co często jest źródłem trudnych do wykrycia bugów.

function logName() {
  console.log(this.name);
  // bez noImplicitThis: this: any
  // z noImplicitThis: błąd, trzeba określić typ this
}

strictPropertyInitialization pilnuje, aby wszystkie pola w klasie zostały zainicjalizowane w konstruktorze lub oznaczone jako opcjonalne / z `!`. To szczególnie ważne przy klasach używanych jako modele domenowe.

Stopniowe „dokręcanie śruby” w istniejącym projekcie

W starszych projektach przejście na pełne `strict` może wygenerować setki błędów. Da się to jednak zrobić iteracyjnie.

Praktyczny plan:

  • Włącz tylko noImplicitAny i popraw najgorsze miejsca (szczególnie publiczne API modułów).
  • Następnie dodaj strictNullChecks. Skup się na kodzie, który ma największy wpływ na bezpieczeństwo (obsługa płatności, logowanie, autoryzacja).
  • Później aktywuj pełne strict i walcz z pozostałymi ostrzeżeniami.
  • W trudnych plikach można tymczasowo użyć komentarza // @ts-nocheck i stopniowo go usuwać w miarę porządkowania kodu.

Lepsze jest częściowe, ale konsekwentne zaostrzenie reguł w nowych modułach niż trwanie na starych ustawieniach w imię „świętego spokoju”.

Ustawienia bezpieczeństwa vs estetyczne

Warto rozdzielić ustawienia, które zwiększają bezpieczeństwo, od tych, które głównie poprawiają higienę kodu:

  • Bezpieczeństwo:
    • strict, noImplicitAny, strictNullChecks, noImplicitThis, strictPropertyInitialization.
  • Estetyka / porządek:
    • noUnusedLocals, noUnusedParameters, noFallthroughCasesInSwitch, noImplicitReturns.

Te „estetyczne” opcje również pomagają, ale ich celem jest raczej utrzymanie czytelności, niż bezpośrednie ograniczanie ryzyka błędów bezpieczeństwa. Mimo to dobrze je włączyć, gdy zespół jest gotów na bardziej wymagający linting.

Typy podstawowe i nullowalność: małe szczegóły, duże konsekwencje

Świadome użycie string | null zamiast domyślnego undefined

W JavaScript `undefined` pojawia się wszędzie: brak argumentu, brak własności, niezainicjalizowana zmienna. W TypeScript łatwo przenieść ten nawyk i dopuszczać `undefined` wszędzie „na wszelki wypadek”. To osłabia system typów.

Lepsza praktyka: używać `null` świadomie, a `undefined` zostawić głównie dla warstwy języka (np. opcjonalne pola w interfejsach, brak parametru).

interface User {
  id: string;
  // email może być nieznany (np. użytkownik z OAuth bez emaila)
  email: string | null;
}

// Złe: miesza null i undefined bez potrzeby
interface BadUser {
  id: string;
  email?: string | null;
}

W `BadUser` masz trzy różne „stany pustki” dla `email`:

  • brak pola (undefined, bo opcjonalne),
  • pole istnieje i ma wartość `null`,
  • pole istnieje i ma wartość `””` (pusty string).

Taki projekt rodzi błędy i przypadkowe pominięcia. Lepszy, spójny kontrakt wymusza rozróżnienie tylko tam, gdzie ma to sens biznesowy.

Pułapki falsy i luźnych porównań

JavaScript traktuje jako falsy: 0, "", null, undefined, false, NaN. W połączeniu z nieprecyzyjnymi typami prowadzi to do subtelnych błędów.

Przykład:

function isLoggedIn(userId?: string): boolean {
  return !!userId;
}

Ten kod używa typów w sposób bardzo luźny. Jeśli `userId` to pusty string, wynik będzie `false`, nawet jeśli taki stan nie powinien się wydarzyć. TypeScript nie ma tu jak pomóc.

Bezpieczniejsza wersja:

type UserId = string;

function isLoggedIn(userId: UserId | null): boolean {
  return userId !== null;
}

Typ od początku wyraźnie sygnalizuje, że jedynym „pustym” stanem jest `null`, a nie `””` czy `undefined`. Porównanie jest jawne, bez magii falsy.

Jak strictNullChecks zmienia sposób pisania kodu

Z włączonym `strictNullChecks` kompilator przestaje zakładać, że każdy typ „może być nullem”. Zmusza to do jawnej obsługi braku wartości.

Przykład bez `strictNullChecks`:

interface Profile {
  name: string;
  bio?: string;
}

function getBioLength(profile: Profile): number {
  return profile.bio.length; // potencjalny runtime error
}

Z `strictNullChecks` pojawi się błąd, ponieważ `bio` jest potencjalnie `undefined`.

Bezpieczniejsza wersja:

function getBioLength(profile: Profile): number {
  if (!profile.bio) {
    return 0;
  }
  return profile.bio.length;
}

lub z użyciem operatora opcjonalnego łańcuchowania:

function getBioLength(profile: Profile): number {
  return profile.bio?.length ?? 0;
}

System typów wymusza w ten sposób jawne podjęcie decyzji: co ma się stać, gdy `bio` nie istnieje.

Kiedy dopuszczać null, a kiedy projektować osobne typy

Prosty schemat projektowania nullowalności:

  • Pole jest opcjonalne w rzeczywistości (np. użytkownik może, ale nie musi mieć numeru telefonu) – użyj unii string | null lub string | undefined, ale konsekwentnie.
  • Dane są w trakcie ładowania – zamiast `User | null` często lepiej działa unia stanów, np. Loading | Loaded | Error.
  • Brak wartości to odrębny stan domenowy (np. brak płatności vs nieustalony status płatności) – projektuj wyraźne typy i unie, zamiast mieszać null w kilka miejsc.

Przykład bezpieczniejszego modelu dla danych ładowanych z API:

Null vs osobne typy stanów

Model typu „albo wartość, albo null” jest prosty, ale szybko się rozpada, gdy stanów jest więcej niż dwa. Zamiast kombinacji flag i nulli lepiej sprawdza się jawne opisanie możliwych stanów.

// Słabe podejście
interface UserState {
  user: User | null;
  loading: boolean;
  error: string | null;
}

Tutaj da się skonstruować sprzeczne stany, np. { user: null, loading: false, error: null } – co to znaczy? System typów tego nie blokuje.

// Bezpieczniejsze podejście
type UserState =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'loaded'; user: User }
  | { status: 'error'; message: string };

W drugiej wersji nie zbudujesz stanu, który jest jednocześnie „loading” i „loaded”. Mniej if-ów w runtime, więcej kontroli w kompilatorze.

Null assertion operator (!) jako narzędzie ostateczne

Operator ! usuwa z typu null/undefined bez żadnej runtime’owej weryfikacji. Nadużywany, zamienia TypeScript w „kolorowy JavaScript”.

function process(user: User | null) {
  // wymuszenie na kompilatorze „tu NA PEWNO jest User”
  doSomething(user!);
}

Jeśli taka funkcja kiedykolwiek dostanie null, program poleci w runtime. Bezpieczniejsza wersja albo filtruje dane wejściowe wcześniej, albo jawnie sprawdza argument:

function process(user: User | null) {
  if (!user) {
    throw new Error('User is required');
  }
  doSomething(user);
}

Operator ! ma sens tylko tam, gdzie faktycznie istnieje zewnętrzna gwarancja (np. framework zawsze wstrzykuje pole po konstrukcji klasy), a opisanie tego w typach jest trudne albo kosztowne.

Unikanie any i bezpieczne użycie unknown

Dlaczego any jest dziurą w grodzi przeciwpożarowej

any wyłącza sprawdzanie typów. Błąd może powstać daleko od miejsca, gdzie any się pojawiło, bo „rozleje się” przez interfejsy i funkcje.

function parseConfig(raw: string): any {
  return JSON.parse(raw);
}

const cfg = parseConfig(env.CONFIG);
console.log(cfg.port.toFixed(0)); // może być undefined w runtime

Komunikacja z zewnętrznymi źródłami to naturalne miejsce na brak typów. Użycie any przenosi ten brak do całego systemu.

unknown jako bezpieczniejszy stopień niepewności

unknown oznacza „nie wiem, co to jest”, ale wymusza weryfikację przed użyciem. To dobre „wejście” z nieufnego świata (input od użytkownika, JSON z API).

function parseConfig(raw: string): unknown {
  return JSON.parse(raw);
}

const cfg = parseConfig(env.CONFIG);

// błąd kompilacji:
// Property 'port' does not exist on type 'unknown'
console.log(cfg.port);

Żeby użyć wartości typu unknown, trzeba ją zawęzić:

interface AppConfig {
  port: number;
  mode: 'dev' | 'prod';
}

function isAppConfig(value: unknown): value is AppConfig {
  if (typeof value !== 'object' || value === null) return false;
  const v = value as Record<string, unknown>;
  return (
    typeof v.port === 'number' &&
    (v.mode === 'dev' || v.mode === 'prod')
  );
}

const rawCfg: unknown = parseConfig(env.CONFIG);

if (!isAppConfig(rawCfg)) {
  throw new Error('Invalid config');
}

startServer(rawCfg.port, rawCfg.mode);

Obowiązek walidacji nie da się tutaj „zapomnieć” – kompilator jej wymaga.

Jak lokalizować i izolować any

W istniejącym kodzie any bywa nieuniknione. Ważne, żeby ograniczyć jego zasięg i nie dopuścić do „wycieku” do API modułów.

  • W środku funkcji: dopuszczalne, jeśli natychmiast zawężasz typ i nie wypuszczasz dalej.
  • W interfejsach modułów / klasach eksportowanych: unikaj, bo trudniej to będzie kiedyś posprzątać.
// Gorsza wersja: any wychodzi na zewnątrz
export function getUser(): any {
  return db.query('...');
}

// Lepsza: any lokalnie, na brzegu – jawny typ na wyjściu
export function getUser(): User {
  const row: any = db.query('...');
  return mapRowToUser(row);
}

Dobry test: jeśli any pojawia się w definicji typu eksportowanego na zewnątrz modułu, jest to sygnał ostrzegawczy.

unknown w bibliotekach i helperach

Funkcje operujące na „dowolnych danych” (np. logger, serializator) zwykle lepiej typować jako unknown niż any. Dzięki temu nie pozwolą po cichu na niebezpieczne operacje.

function safeStringify(value: unknown): string {
  if (typeof value === 'string') return value;
  if (value instanceof Error) return value.stack ?? value.message;
  return JSON.stringify(value);
}

W ten sposób helper nie narzuca konsumentom wątpliwych założeń i nie wymaga rzutowania w każdym użyciu.

Zbliżenie na ekran z kodem Ruby on Rails w edytorze programistycznym
Źródło: Pexels | Autor: Digital Buggu

Typy domenowe zamiast prymitywów: modelowanie bezpieczniejszej logiki

Stringi i numbery to za mało

Reprezentowanie wszystkiego jako string lub number ułatwia wprowadzanie błędów. System typów nie rozróżnia wtedy ID użytkownika od ID zamówienia ani kwoty netto od brutto.

// typowo spotykany kod
function sendInvoice(userId: string, amount: number) {
  // ...
}

Każde wywołanie takiej funkcji można łatwo pomylić:

sendInvoice(order.id, user.id); // kompilator nie zaprotestuje

Branding typów (nominalne typy nad prymitywami)

Prosty trik: „brandowanie” typów przez dodanie prywatnego pola, które rozróżnia typy na poziomie kompilatora, choć w runtime dalej są stringami.

type UserId = string & { readonly __brand: 'UserId' };
type OrderId = string & { readonly __brand: 'OrderId' };

function makeUserId(raw: string): UserId {
  return raw as UserId;
}

function makeOrderId(raw: string): OrderId {
  return raw as OrderId;
}

Teraz:

function sendInvoice(userId: UserId, orderId: OrderId) {
  // ...
}

const userId = makeUserId('u_123');
const orderId = makeOrderId('o_456');

sendInvoice(userId, orderId);      // OK
sendInvoice(orderId, userId);      // błąd kompilacji

Po stronie runtime to dalej zwykłe stringi, więc koszt jest zerowy. Zysk – brak całej klasy pomyłek.

Typy kwot, walut, jednostek

Dane liczbowe bez kontekstu są ryzykowne. Systemu typów można użyć do odróżnienia np. złotówek od groszy czy brutto od netto.

type PLN = number & { readonly __currency: 'PLN' };
type Cents = number & { readonly __unit: 'Cents' };

function pln(value: number): PLN {
  return value as PLN;
}

function cents(value: number): Cents {
  return value as Cents;
}

function addAmounts(a: PLN, b: PLN): PLN {
  return pln(a + b);
}

Dzięki temu kompilator uniemożliwi np. dodanie groszy do złotówek bez jawnej konwersji.

Encje domenowe jako typy, nie worki na dane

Model domeny zyskuje, gdy nie jest po prostu odzwierciedleniem tabeli z bazy, ale niesie też reguły biznesowe. Część z nich można wbudować w typy.

// Bardzo luźny model
interface Payment {
  id: string;
  amount: number;
  status: string; // 'new', 'paid', 'failed', ...
}

Tu da się wcisnąć dowolny status. Można je zamienić na ustaloną unię i typy częściowe, które reprezentują etapy procesu:

type PaymentStatus = 'new' | 'authorized' | 'captured' | 'failed';

interface BasePayment {
  id: string;
  amount: PLN;
}

interface NewPayment extends BasePayment {
  status: 'new';
}

interface AuthorizedPayment extends BasePayment {
  status: 'authorized';
  authorizationCode: string;
}

Dalej można dopisywać funkcje, które przyjmują tylko określone podtypy:

function capture(payment: AuthorizedPayment): CapturedPayment {
  // ...
}

Nie da się wtedy „przypadkiem” wywołać capture na płatności w statusie 'new'.

Typy unijne, wyliczeniowe i discriminated unions jako ochrona przed stanami niemożliwymi

Enum vs unia stringów

enum bywa wygodny, ale w TypeScript często wystarczy unia literalnych stringów. Jest prostsza, bez pułapek związanych z mapowaniem numerycznym.

type Role = 'admin' | 'user' | 'guest';

function canDeleteUser(role: Role): boolean {
  switch (role) {
    case 'admin':
      return true;
    case 'user':
    case 'guest':
      return false;
    default:
      // przy włączonym strictNullChecks i exhaustiveness check
      // kompilator zgłosi błąd, jeśli pojawi się nowa rola
      const _exhaustive: never = role;
      return _exhaustive;
  }
}

Dodanie nowej roli wymusza aktualizację wszystkich switchy, które ją obsługują. To wymuszony, zdrowy ból przy rozszerzaniu domeny.

Discriminated unions: jedno pole, wiele kształtów

Discriminated union to unia, w której każdy wariant ma wspólne pole „rozróżniające” (discriminant). Dzięki temu TypeScript potrafi inteligentnie zawężać typ.

type TwoFactorMethod =
  | { type: 'sms'; phone: string }
  | { type: 'totp'; secret: string }
  | { type: 'backup_code'; codesLeft: number };

Funkcja operująca na takim typie jest prosta i odporna na pomyłki:

function describeMethod(method: TwoFactorMethod): string {
  switch (method.type) {
    case 'sms':
      return `SMS na numer ${method.phone}`;
    case 'totp':
      return 'Aplikacja TOTP';
    case 'backup_code':
      return `Kody zapasowe (${method.codesLeft} pozostało)`;
  }
}

Po wejściu w konkretny case method ma już dokładny typ. Nie trzeba rzutowań, nie da się odczytać secret z wariantu 'sms'.

Modelowanie workflow zamiast flag i booleanów

Typowa pułapka: obiekt z kilkoma booleanami, które w teorii mają określoną logikę, w praktyce można połączyć je dowolnie.

// przykładowo
interface Order {
  isPaid: boolean;
  isShipped: boolean;
  isCancelled: boolean;
}

Taki model pozwala na stany „zapłacone i anulowane jednocześnie”. Zdecydowanie lepszy jest pojedynczy status opisany uniami.

type OrderStatus =
  | 'new'
  | 'paid'
  | 'shipped'
  | 'cancelled';

interface Order {
  id: OrderId;
  status: OrderStatus;
}

Jeszcze dalej można pójść z discriminated unions opisując konkretne etapy, jak przy płatnościach. Zależnie od złożoności domeny można dobrać poziom szczegółowości.

Wyliczanie wszystkich przypadków (exhaustive checks)

Przy bardziej rozbudowanych uniach dobrze jest wymusić, żeby każdy switch pokrywał wszystkie warianty. Służy do tego znany trik z typem never.

type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'square'; size: number };

function area(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2;
    case 'square':
      return shape.size ** 2;
    default:
      const _exhaustive: never = shape;
      return _exhaustive;
  }
}

Po dodaniu nowego wariantu, np. 'triangle', kompilator od razu wskaże wszystkie miejsca, gdzie trzeba dopisać obsługę. To dobre zabezpieczenie przed „cichym” wprowadzeniem błędów przy rozwoju systemu.

Struktury danych i kolekcje: generics, mapowania typów, Record

Silne typowanie kolekcji z generics

Tablice i mapy bez parametrów generycznych są jak zmienne typu any – pozwalają na wszystko.

const users: any[] = [];
users.push(1);
users.push('abc');

Wprowadzenie generyków usztywnia strukturę:

const users: User[] = [];
users.push({ id: 'u1', email: 'a@example.com' });
// users.push(1); // błąd kompilacji

To samo dotyczy Map i innych struktur:

const sessions = new Map<SessionId, UserId>();

sessions.set(makeSessionId('s1'), makeUserId('u1'));

Record i typowane słowniki

Record<K, V> jest wygodną formą słownika, gdzie klucz i wartość są ściśle określone typami.

type FeatureFlag = 'betaCheckout' | 'newUI';

type FeatureFlags = Record<FeatureFlag, boolean>;

const flags: FeatureFlags = {
  betaCheckout: true,
  newUI: false,
};

Dodanie nowego klucza do FeatureFlag wymusza dopisanie go wszędzie, gdzie używasz Record. Nie da się też literówkować nazwy flagi.

Dla luźniejszych kluczy (np. ID z bazy) można wykorzystać Record<string, T>, ale lepiej stosować brandowane ID zamiast surowego string.

Mapowane typy do bezpiecznych transformacji

Narzzucone kształty obiektów za pomocą mapowanych typów

Mapowane typy pozwalają „przemodelować” istniejące struktury tak, żeby wymusić spójność między danymi, a tym jak są przetwarzane.

interface UserSettings {
  emailNotifications: boolean;
  smsNotifications: boolean;
  darkMode: boolean;
}

type UserSettingsPatch = Partial<UserSettings>;

Partial jest mapowanym typem z biblioteki standardowej. Da się jednak zdefiniować własne warianty, dopasowane do potrzeb bezpieczeństwa.

type ReadonlyDeep<T> = {
  readonly [K in keyof T]: T[K] extends object
    ? ReadonlyDeep<T[K]>
    : T[K];
};

Taki typ zamraża strukturę w głąb, co ogranicza przypadkowe mutacje w krytycznych fragmentach, np. konfiguracji bezpieczeństwa.

interface SecurityConfig {
  passwordPolicy: {
    minLength: number;
    requireSymbols: boolean;
  };
  lockoutThreshold: number;
}

const config: ReadonlyDeep<SecurityConfig> = {
  passwordPolicy: {
    minLength: 12,
    requireSymbols: true,
  },
  lockoutThreshold: 5,
};

// config.lockoutThreshold = 10; // błąd
// config.passwordPolicy.minLength = 8; // też błąd

Mapowane typy do bezpiecznego przepuszczania danych

Często ten sam kształt obiektu musi mieć różne „profile bezpieczeństwa”: pełny, do logów, do odpowiedzi API itd.

interface User {
  id: UserId;
  email: string;
  passwordHash: string;
  twoFactorEnabled: boolean;
}

Lepsze niż ręczne przepisywanie typów jest stworzenie mapy pól wrażliwych.

type SensitiveFields = 'passwordHash';

type RedactSensitive<T, K extends keyof T> = Omit<T, K> & {
  [P in K]: 'REDACTED';
};

type SafeUserForLogs = RedactSensitive<User, SensitiveFields>;

const safeUser: SafeUserForLogs = {
  id: makeUserId('u1'),
  email: 'a@example.com',
  passwordHash: 'REDACTED',
  twoFactorEnabled: true,
};

Transformacja typów wymusza, żeby żadne miejsce nie wypuściło passwordHash „na zewnątrz” bez zamazania.

Typowane funkcje generyczne na kolekcjach

Ślepe „utility” działające na any sprzyjają błędom. Funkcje generyczne usztywniają kontrakty.

function groupBy<T, K extends string | number | symbol>(
  items: T[],
  keyFn: (item: T) => K
): Record<K, T[]> {
  return items.reduce((acc, item) => {
    const key = keyFn(item);
    (acc[key] ??= []).push(item);
    return acc;
  }, {} as Record<K, T[]>);
}

Wywołanie takiej funkcji zachowuje pełną informację o typach.

const usersByDomain = groupBy(users, u => u.email.split('@')[1]);

// usersByDomain['example.com'][0].id   // typ to UserId, nie any

Dodatkowa korzyść: brak magii przy zmianie modelu. Jeśli zniknie pole email, błąd pojawi się przy kompilacji, a nie w logach produkcyjnych.

Bezpieczne indeksowanie: unikanie „luźnego” stringa w kluczach

Indeksowanie po string otwiera drogę do literówek i dostępu do nieistniejących pól.

type Translations = Record<string, string>;

const t: Translations = {};
t['login.button'] = 'Zaloguj'; // OK
t['login.buton'] = 'Zaloguj'; // literówka, kompilator milczy

Zdefiniowanie unii dopuszczalnych kluczy mocno ogranicza ten problem.

type TranslationKey =
  | 'login.button'
  | 'login.title'
  | 'logout.button';

type StrongTranslations = Record<TranslationKey, string>;

const st: StrongTranslations = {
  'login.button': 'Zaloguj',
  'login.title': 'Logowanie',
  'logout.button': 'Wyloguj',
  // 'login.buton': '...' // błąd – zły klucz
};

Da się też wygenerować klucze z obiektu źródłowego, żeby nie powielać listy.

const baseTranslations = {
  'login.button': 'Zaloguj',
  'login.title': 'Logowanie',
  'logout.button': 'Wyloguj',
} as const;

type TranslationKeyFromObject = keyof typeof baseTranslations;

Granice systemu: walidacja danych z zewnątrz i integracja z runtime

System typów nie chroni przed danymi spoza TypeScriptu

Każdy punkt styku z zewnętrznym światem to potencjalne źródło niezgodnych danych: HTTP, kolejki, baza, pliki konfiguracyjne.

interface LoginRequest {
  email: string;
  password: string;
}

app.post('/login', (req, res) => {
  const body: LoginRequest = req.body; // tylko asercja, brak gwarancji
});

Tu kompilator jedynie ufa, że req.body ma odpowiedni kształt. Złośliwy lub błędny klient może wysłać cokolwiek.

Walidacja schematem runtime + inferencja typów

Bezpieczniejsze podejście to walidacja runtime połączona z wyprowadzeniem typu z deklarowanego schematu.

import { z } from 'zod';

const LoginRequestSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
});

type LoginRequestSafe = z.infer<typeof LoginRequestSchema>;

Typy nie są pisane podwójnie: definicja walidacji jest źródłem prawdy, a TypeScript wyciąga z niej statyczny typ.

app.post('/login', (req, res) => {
  const parseResult = LoginRequestSchema.safeParse(req.body);

  if (!parseResult.success) {
    return res.status(400).json({ error: 'Invalid payload' });
  }

  const body: LoginRequestSafe = parseResult.data;
  // od tego miejsca body jest bezpieczne typowo i walidacyjne
});

Podobnie można opisać odpowiedzi API, eventy w kolejce, rekordy z bazy – wszędzie tam, gdzie JSON wchodzi lub wychodzi z systemu.

Zacieśnianie typów po walidacji: type guards

Type guard to funkcja, która w runtime coś sprawdza, a w systemie typów zawęża typ argumentu.

function isUserId(value: string): value is UserId {
  return value.startsWith('u_');
}

function getUser(id: string) {
  if (!isUserId(id)) {
    throw new Error('Invalid user id');
  }

  // od tej linii id ma typ UserId
  return loadUserFromDb(id);
}

Dzięki temu w dalszym kodzie nie trzeba już rzutować, a kompilator wymusza użycie wcześniej przefiltrowanego typu.

Bezpieczna deserializacja zamiast bezmyślnego JSON.parse

JSON.parse zwraca any. To prosta droga do użycia błędnego pola lub złego typu.

const raw = localStorage.getItem('session');
if (!raw) throw new Error('No session');

const session = JSON.parse(raw);
// session.user.id może nie istnieć, a typ tego nie ujawnia

Lepszym wzorcem jest wrapper, który łączy parse z walidacją.

const SessionSchema = z.object({
  userId: z.string(),
  expiresAt: z.string().datetime(),
});

type Session = z.infer<typeof SessionSchema>;

function parseSession(raw: string): Session {
  const result = SessionSchema.safeParse(JSON.parse(raw));
  if (!result.success) {
    throw new Error('Invalid session data');
  }
  return result.data;
}

Teraz każda funkcja operująca na Session ma pełne wsparcie typów i pewność, że dane zostały sprawdzone.

Oddzielenie typów DTO od typów domenowych

Struktura payloadu HTTP rzadko jest idealnym modelem domenowym. Mieszanie jednego z drugim kończy się lawiną optional i pól, które niby są, ale nie zawsze.

interface CreateOrderDto {
  userId: string;
  items: Array<{
    productId: string;
    quantity: number;
  }>;
  couponCode?: string;
}

Po walidacji DTO lepiej jest przemapować je na bogatsze typy domenowe.

interface OrderItem {
  productId: ProductId;
  quantity: number;
}

interface CreateOrderCommand {
  userId: UserId;
  items: OrderItem[];
  coupon?: CouponCode;
}
function mapDtoToCommand(dto: CreateOrderDto): CreateOrderCommand {
  return {
    userId: makeUserId(dto.userId),
    items: dto.items.map(i => ({
      productId: makeProductId(i.productId),
      quantity: i.quantity,
    })),
    coupon: dto.couponCode
      ? makeCouponCode(dto.couponCode)
      : undefined,
  };
}

Dalsza logika biznesowa nie musi już myśleć o strukturze HTTP ani przypadkowych brakach danych.

Typy a kontrola uprawnień

Uprawnienia są klasycznym miejscem na subtelne błędy. Rozdzielenie ról w typach pozwala na wcześniejsze wychwycenie wielu z nich.

type AuthenticatedUser =
  | { role: 'admin'; id: UserId }
  | { role: 'user'; id: UserId }
  | { role: 'guest' };
function deleteUser(targetId: UserId, actor: AuthenticatedUser) {
  if (actor.role !== 'admin') {
    throw new Error('Forbidden');
  }

  // w tym bloku actor jest zawężony do { role: 'admin'; id: UserId }
}

Dzięki discriminated union nie da się „zapomnieć” o sprawdzeniu roli. Kompilator wymaga obsługi wszystkich wariantów, więc przypadkowe pominięcie ścieżki dla gościa szybko wychodzi na jaw.

Integracja z bibliotekami niepisanymi w TypeScript

Bibioteki bez deklaracji typów często wymuszają użycie any albo ręcznych rzutowań. Lepiej jest otoczyć je cienką warstwą z własnymi typami.

// zewnętrzny moduł JS
declare const legacyEncrypt: (payload: string, key: string) => string;

type EncryptionKey = string & { __brand: 'EncryptionKey' };

function makeEncryptionKey(raw: string): EncryptionKey {
  if (raw.length < 32) {
    throw new Error('Key too short');
  }
  return raw as EncryptionKey;
}

function encryptJson<T>(data: T, key: EncryptionKey): string {
  const serialized = JSON.stringify(data);
  return legacyEncrypt(serialized, key);
}

Reszta kodu nie ma już dostępu do surowego legacyEncrypt. Każde wywołanie przechodzi przez zweryfikowany kontrakt i brandowany klucz.

Bezpieczne typowanie konfiguracji i feature flag z zewnątrz

Konfiguracje z plików lub zmiennych środowiskowych są typowo „any”, a literówki w nazwach kluczy powodują ciche błędy.

const config = {
  JWT_SECRET: process.env.JWT_SECRET,
  RATE_LIMIT: Number(process.env.RATE_LIMIT ?? '100'),
} as const;

Można zbudować nad tym małą warstwę typów i walidacji.

const ConfigSchema = z.object({
  JWT_SECRET: z.string().min(32),
  RATE_LIMIT: z.number().int().positive(),
});

type AppConfig = z.infer<typeof ConfigSchema>;

function loadConfig(): AppConfig {
  const maybeConfig = {
    JWT_SECRET: process.env.JWT_SECRET,
    RATE_LIMIT: Number(process.env.RATE_LIMIT ?? '100'),
  };

  const result = ConfigSchema.safeParse(maybeConfig);

  if (!result.success) {
    throw new Error('Invalid config');
  }

  return result.data;
}

const appConfig = loadConfig();

Każde miejsce w kodzie korzystające z appConfig jest chronione zarówno przez typy, jak i walidację runtime.

Bezpieczne API modułów: eksportuj wąskie, mocno typowane interfejsy

Moduły często eksportują zbyt wiele rzeczy: surowe modele, funkcje pomocnicze, wewnętrzne typy. To utrudnia kontrolę nad tym, jak inni programiści używają danego fragmentu systemu.

// paymentService.ts
interface PaymentService {
  create(payment: NewPayment): Promise<AuthorizedPayment>;
  capture(payment: AuthorizedPayment): Promise<CapturedPayment>;
}

async function create(...) { /* ... */ }
async function capture(...) { /* ... */ }
function internalHelper(...) { /* ... */ }

export const paymentService: PaymentService = {
  create,
  capture,
};

Eksport interfejsu zamiast pojedynczych funkcji ogranicza przestrzeń błędów. Konsumenci modułu nie mogą przypadkowo sięgnąć po wewnętrzne szczegóły, które nie są objęte kontraktem typów.

Co warto zapamiętać

  • TypeScript zabezpiecza głównie kontrakty typów i struktur danych (parametry funkcji, właściwości obiektów, null/undefined), a nie ataki XSS, CSRF czy SQL injection – te wymagają osobnej walidacji i architektury.
  • Bezpieczniejsza aplikacja to nie tylko mniej „Cannot read property of undefined”, ale też brak stanów niemożliwych, wyraźne oddzielenie danych zewnętrznych od zweryfikowanych i typy domenowe utrudniające pomyłki (np. zamiana UserId z OrderId).
  • Dobrze zaprojektowane typy są żywą dokumentacją i twardym kontraktem – z samej sygnatury funkcji widać, czego ona realnie potrzebuje i co zwraca, bez zgadywania po implementacji.
  • Silne typowanie zmniejsza ryzyko „cichych” błędów przy refaktoryzacji: zmiana np. statusu zamówienia ze stringa na unię wymusza poprawki wszędzie tam, gdzie kompilator zgłosi konflikt typów.
  • Konfiguracja kompilatora jest krytyczna dla bezpieczeństwa: tryb "strict": true z flagami takimi jak noImplicitAny czy strictNullChecks zamyka dużą klasę błędów związanych z niejawnie dopuszczonymi typami.
  • Wymuszanie jawnych typów (np. w funkcjach add(a: number, b: number)) zmusza do sprecyzowania intencji i ogranicza sytuacje, w których any ukrywa potencjalnie niebezpieczne operacje.
  • Świadome użycie typów polega na maksymalnym „wyciągnięciu” tego, co da się zweryfikować w compile-time, oraz na jasnym oznaczeniu miejsc, gdzie potrzebna jest dodatkowa walidacja w runtime.