Back to Insights
TypeScriptJuly 25, 20267 min read

TypeScript for Reliable Boundaries, Not Decorative Types

TypeScript is most valuable when it makes trusted states precise and forces untrusted input through runtime validation, rather than decorating unsafe values with assertions.

John Mather, Founder of Prime Axiom AI
John Mather

Founder, Prime Axiom AI

Published July 25, 2026

Technical illustration for TypeScript for Reliable Boundaries, Not Decorative Types
TypeScript for Reliable Boundaries, Not Decorative Types — Prime Axiom Insights.

TypeScript for Reliable Boundaries, Not Decorative Types

TypeScript can prove relationships inside the program the compiler sees. It cannot prove that an HTTP body, environment variable, database row, local-storage value, or third-party response matches a declared interface. Reliable TypeScript systems make this trust boundary explicit: external values enter as unknown, runtime code validates and transforms them, and only then does the domain receive a precise type.

Decorative typing does the opposite. It writes an interface and asserts that untrusted data has that shape. The editor becomes quiet while runtime risk remains.

The assertion that proves nothing

type CreateOrder = { customerId: string quantity: number }

const input = (await request.json()) as CreateOrder return createOrder(input)

The as assertion emits no validation. A caller can send a missing customerId, a string quantity, additional dangerous fields, or malformed JSON. The program has instructed the compiler to trust the developer.

A safer boundary begins with unknown and narrows:

function isCreateOrder(value: unknown): value is CreateOrder { if (typeof value !== 'object' || value === null) return false const record = value as Record<string, unknown> return ( typeof record.customerId === 'string' && record.customerId.length > 0 && Number.isInteger(record.quantity) && (record.quantity as number) > 0 ) }

const input: unknown = await request.json() if (!isCreateOrder(input)) { return Response.json({ code: 'INVALID_ORDER' }, { status: 400 }) } return createOrder(input)

The type guard performs runtime checks and narrows input for subsequent code. A production validator should return field-level errors, reject or intentionally strip unknown properties, apply size limits, and distinguish syntax from business rules. A schema library can reduce hand-written mistakes, but the boundary principle remains.

Map the trust perimeter

mermaid
flowchart LR
  U[unknown external value] --> P[parse syntax]
  P --> V[validate shape and limits]
  V --> T[transform units and names]
  T --> D[trusted domain type]
  D --> S[serialize explicit output]

Validation is not the final domain decision. A syntactically valid order can reference a nonexistent customer or exceed inventory. Keep structural validation at the adapter and business invariants in domain services.

BoundaryRuntime concernUseful response
HTTP JSONsize, shape, unknown keysschema validation and typed error
Environmentmissing values, numeric parsingvalidate at startup
Databasemigration drift, nullable legacy rowsrow decoder
Queue eventversion, duplicate, incompatible producerversioned event parser
Vendor APIundocumented nulls, error payloadsadapter and quarantine
Browser storagestale or user-modified stateparse and migrate

The table shows why generated types alone are insufficient. OpenAPI generation aligns compile-time clients, but the server and client still receive bytes at runtime.

Make invalid states harder to represent

Primitive strings lose meaning. Branded types can prevent accidental identifier mixing after validation:

declare const CustomerIdBrand: unique symbol type CustomerId = string & { readonly [CustomerIdBrand]: true }

function parseCustomerId(value: unknown): CustomerId { if (typeof value !== 'string' || !/^cus_[a-z0-9]+$/.test(value)) { throw new Error('Invalid customer identifier') } return value as CustomerId }

The assertion is safe only inside the function because runtime validation establishes the brand’s invariant. Do not export a public cast that lets callers manufacture CustomerId.

Discriminated unions model finite states:

type Payment = | { state: 'pending'; attemptId: string } | { state: 'captured'; receiptId: string; amountCents: number } | { state: 'failed'; code: string; retryable: boolean }

function message(payment: Payment): string { switch (payment.state) { case 'pending': return Attempt ${payment.attemptId} is pending case 'captured': return Receipt ${payment.receiptId} captured case 'failed': return payment.retryable ? 'Retry available' : 'Payment failed' default: return assertNever(payment) } }

function assertNever(value: never): never { throw new Error(Unhandled state: ${JSON.stringify(value)}) }

The discriminant determines available fields. Adding a new state makes the switch fail compilation. The escaped template literal markers are required because this article body itself is stored in a template literal.

Absence deserves a design

With strictNullChecks, undefined and null remain visible. Decide their semantics. A PATCH request needs to distinguish omitted “leave unchanged,” null “clear value,” and a supplied replacement. Optional properties alone can obscure that distinction.

type ProfilePatch = | { operation: 'leave-display-name' } | { operation: 'clear-display-name' } | { operation: 'set-display-name'; value: string }

This explicit command is verbose and unambiguous. At a wire boundary, an adapter can convert conventional JSON into the command after validation. Domain code no longer depends on subtle property presence.

Enable strict compiler options deliberately. strict is the baseline. noUncheckedIndexedAccess makes array and map lookups possibly undefined. exactOptionalPropertyTypes distinguishes omission from explicit undefined. useUnknownInCatchVariables prevents assuming every thrown value is Error. Adoption may be staged, but new packages should not create more unsoundness.

{ "compilerOptions": { "strict": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true, "useUnknownInCatchVariables": true, "noImplicitOverride": true } }

This configuration increases useful friction at uncertain operations. It does not validate runtime input or replace tests.

Error results are part of contracts

Throwing exceptions for every expected outcome makes APIs hard to reason about. A result type can expose recoverable domain outcomes:

type Result<T, E> = | { ok: true; value: T } | { ok: false; error: E }

type ReserveError = | { code: 'OUT_OF_STOCK'; available: number } | { code: 'PRODUCT_NOT_FOUND' } | { code: 'CONFLICT'; retryAfterMs: number }

async function reserve(command: ReserveCommand): Promise<Result<Reservation, ReserveError>> { // domain implementation }

Callers must branch on ok and can exhaustively handle error codes. Unexpected infrastructure failures may still throw and be translated at an application boundary. Avoid a generic Result everywhere if it obscures programming errors.

Generics should preserve relationships

Useful generics connect input and output types:

function groupBy<T, K extends PropertyKey>( items: readonly T[], keyOf: (item: T) => K ): Map<K, T[]> { const result = new Map<K, T[]>() for (const item of items) { const key = keyOf(item) result.set(key, [...(result.get(key) ?? []), item]) } return result }

The key callback determines K and the map preserves T. By contrast, a generic appearing only once often adds ceremony without a relationship. Avoid conditional-type puzzles when a named domain type communicates more clearly.

Boundary testing walkthrough

For CreateOrder, build a table with valid minimum and maximum quantities, missing fields, wrong primitives, null, arrays, extra keys, huge strings, fractional quantities, negative quantities, malformed JSON, and wrong content type. Test the runtime validator independently, then test the HTTP adapter’s status and error shape.

Add property-based tests that every accepted value satisfies domain preconditions and the serialized output round-trips where promised. Contract-test generated clients against the API schema. Mutation-test critical validators to reveal assertions that do not notice removed checks.

At a queue boundary, retain fixtures for every supported event version. Verify old producers remain compatible and unknown future versions go to a quarantine path rather than being cast. At the database boundary, test rows created before and after migrations, especially nullable legacy data.

const cases: Array<[unknown, boolean]> = [ [{ customerId: 'cus_a1', quantity: 1 }, true], [{ customerId: 'cus_a1', quantity: 0 }, false], [{ customerId: 'cus_a1', quantity: 1.5 }, false], [{ customerId: 7, quantity: 1 }, false], [null, false], ]

test.each(cases)('CreateOrder parser', (value, accepted) => { expect(isCreateOrder(value)).toBe(accepted) })

This table is intentionally small and should be expanded. It demonstrates that tests exercise runtime values, not only compile-time examples.

Operations and failure patterns

Track validation failures by boundary, version, and stable error code. Sudden increases can reveal a producer rollout or attack, but never label metrics with raw payloads. Quarantine incompatible events with access controls and replay tooling. Include schema and application versions in traces.

Common anti-patterns are any at boundaries, double assertions through unknown, non-null assertions used as error handling, types copied manually from schemas, enum values assumed closed when a provider can add one, and validators that silently coerce “1” into 1. Another is returning database entities directly, exposing accidental columns and nullability.

Migration toward stricter types should follow risk. Start at external adapters and shared domain contracts, then move inward. Add runtime parsers before replacing broad types so compiler success does not conceal unsafe assertions. Track remaining any, suppression comments, and non-null assertions as debt categories, but review context rather than chasing a raw count.

When a validator changes, consider compatibility like an API change. Tightening a rule can reject existing stored values or older producers. Run the new parser in observation mode, quantify invalid categories without logging payloads, migrate data or producers, and then enforce.

Document which layer owns each conversion and error code.

Data pipeline design applies versioned validation to events. Testing modern systems shows where type checks, contract tests, and integration tests complement one another. See Prime Axiom resources for implementation support.

Conclusion: spend types where trust changes

TypeScript delivers its greatest reliability at boundaries and state models. Receive external input as unknown, validate at runtime, transform into precise domain types, represent finite states explicitly, and serialize deliberate outputs. The goal is not to maximize type syntax. It is to make assumptions visible exactly where untrusted bytes become trusted decisions.

Authoritative references

Apply the idea

Turn technical clarity into a working system.

Talk with Prime Axiom about architecture, AI automation, integrations, and the operational workflow behind your next build.

Continue reading

Technical illustration for Python Asyncio: Concurrency for I/O-Bound Work
Python Asyncio: Concurrency for I/O-Bound Work — Prime Axiom Insights.
Python

July 25, 2026 · 7 min read

Python Asyncio: Concurrency for I/O-Bound Work

Asyncio improves I/O-bound throughput when tasks spend time waiting, but production safety depends on bounded concurrency, cancellation, deadlines, and blocking-code isolation.

By John Mather
Read article →
Technical illustration for Web Accessibility Engineering: Build Inclusion Into Components
Web Accessibility Engineering: Build Inclusion Into Components — Prime Axiom Insights.
Accessibility

July 25, 2026 · 7 min read

Web Accessibility Engineering: Build Inclusion Into Components

Accessibility is a component engineering discipline spanning semantics, keyboard behavior, focus, visual presentation, assistive technology, automated tests, and manual walkthroughs.

By John Mather
Read article →