Advanced TypeScript Patterns

Planted 02026-05-20

Make the hard type machinery invisible and give users precise autocomplete, safe composition, and readable errors.

Read the TypeScript Handbook, especially

1. Concentrate type information in one “type-bearing” value

Don’t ask users to pass generics everywhere. Create one rich value whose type carries the whole graph.

Examples:

const appRouter = router({
  users: usersRouter,
  posts: postsRouter,
})

type AppRouter = typeof appRouter

Library pattern:

type ProcedureDef = {
  _def: {
    $types: {
      input: unknown
      output: unknown
      context: unknown
      meta: unknown
    }
  }
}

Then project from it:

type InferInput<T> =
  T extends { _def: { $types: { input: infer I } } }
    ? I
    : never

type InferOutput<T> =
  T extends { _def: { $types: { output: infer O } } }
    ? O
    : never

Put the type graph somewhere stable, private-ish, and machine-readable: _def, $types, routeTree, schema, config, etc.


2. Use fluent builders as type accumulators

Fluent APIs let users build complex typed objects without writing generics manually.

const getUser = procedure
  .input(userInputSchema)
  .middleware(authMiddleware)
  .query(({ input, ctx }) => {
    return ctx.db.user.find(input.id)
  })

Internally, each method returns a new builder with updated phantom types:

type ProcedureBuilder<TInput, TOutput, TContext> = {
  input<TNewInput>(
    schema: Schema<TNewInput>
  ): ProcedureBuilder<TNewInput, TOutput, TContext>

  use<TNewContext>(
    middleware: Middleware<TContext, TNewContext>
  ): ProcedureBuilder<TInput, TOutput, TNewContext>

  query<TNewOutput>(
    resolver: Resolver<TInput, TNewContext, TNewOutput>
  ): Procedure<TInput, TNewOutput, TNewContext>
}

Fluent builders are great to preserve inference while accumulating type state.


3. Prefer inference over explicit generics

For application developers, this is ideal:

const route = createRoute({
  path: '/users/$userId',
  validateSearch: userSearchSchema,
})

Instead of:

createRoute<
  '/users/$userId',
  { userId: string },
  UserSearch,
  UserContext
>(...)

Library APIs should infer from values:

function createRoute<const TPath extends string, TSearch>(
  options: {
    path: TPath
    validateSearch?: Schema<TSearch>
  }
): Route<TPath, TSearch> {
  // ...
}

Make runtime declarations the source of truth, then derive types from them.


4. Use const type parameters to preserve literals

For routing, events, command names, field names, and registries, literal preservation is critical.

function defineCommand<const TName extends string>(
  name: TName
): Command<TName> {
  // ...
}

Without const, values often widen:

'create-user' -> string

With const, they stay precise:

'create-user' -> 'create-user'

Use const type parameters for public APIs where literal strings matter.


5. Project types with conditional extractors

A common pattern is: accept a rich object, then extract one useful slice.

type InferRouteParams<TRoute> =
  TRoute extends Route<any, infer TParams, any>
    ? TParams
    : never

type InferRouteSearch<TRoute> =
  TRoute extends Route<any, any, infer TSearch>
    ? TSearch
    : never

This gives consumers small, useful helpers:

type UserParams = InferRouteParams<typeof userRoute>
type UserSearch = InferRouteSearch<typeof userRoute>

Expose Infer* helpers. They turn advanced internal types into ergonomic user-facing types.


6. Recursively map over user-defined trees

Routers, route trees, schema trees, command registries, and plugin maps often need recursive type projection.

type InferRouterOutputs<TRouter> = {
  [K in keyof TRouter]:
    TRouter[K] extends Procedure<any, infer TOutput>
      ? TOutput
      : TRouter[K] extends Record<string, any>
        ? InferRouterOutputs<TRouter[K]>
        : never
}

Input:

const router = {
  users: {
    list: procedure.query(() => [{ id: '1' }]),
  },
}

Output:

type Outputs = {
  users: {
    list: { id: string }[]
  }
}

Recursive mapped types let users organize code naturally while preserving deep inference.


7. Use module augmentation as a global type registry

Some libraries need the app’s root type to be “globally known.”

declare module '@acme/router' {
  interface Register {
    router: typeof router
  }
}

Then library APIs can default to the registered type:

type RegisteredRouter =
  Register extends { router: infer TRouter }
    ? TRouter
    : never

This enables APIs like:

<Link to="/users/$userId" params={{ userId: '123' }} />

without needing:

<Link<typeof router> ... />

Module augmentation is powerful for app-wide registries, especially routers, stores, themes, i18n keys, and plugin systems.


8. Validate user options by deriving the intended canonical type

A sophisticated pattern is to accept partial user options, infer intent, then constrain them against the canonical shape.

type InferTo<TOptions> =
  TOptions extends { to: infer TTo }
    ? TTo
    : undefined

type ValidateNavigateOptions<TOptions, TRouter> =
  TOptions extends NavigateOptions<TRouter, InferTo<TOptions>>
    ? TOptions
    : never

This lets users write natural code:

navigate({
  to: '/users/$userId',
  params: { userId: '123' },
})

while the library checks whether params matches to.

Infer from the user’s object first, then validate the object against the inferred target.


9. Design intentional error types

Advanced libraries can improve error messages by returning readable diagnostic types.

type CheckPath<TPath, TValidPaths> =
  TPath extends TValidPaths
    ? TPath
    : {
        error: 'Invalid route path'
        validPaths: TValidPaths
      }

Instead of a mysterious structural mismatch, users see:

{
  error: 'Invalid route path'
  validPaths: '/users' | '/posts' | '/settings'
}

Use friendly error payloads at key failure points. Type errors are part of your API.


10. Use Constrain helpers to balance inference and strictness

A useful pattern:

type Constrain<T, TConstraint, TDefault = TConstraint> =
  | (T extends TConstraint ? T : never)
  | TDefault

This lets the compiler keep inference flexible while still putting pressure on invalid inputs.

A simpler version:

type Ensure<T, TConstraint> =
  T extends TConstraint ? T : never

Constraints should guide inference, not destroy it too early.


11. Normalize complex unions before exposing them

Advanced TS libraries often create huge unions internally. Normalize them before exposing them.

type UnionToIntersection<T> =
  (T extends any ? (x: T) => void : never) extends
  (x: infer I) => void
    ? I
    : never

Useful for merging plugin-provided types:

type PluginContexts =
  | { auth: AuthContext }
  | { db: DbContext }

type AppContext = UnionToIntersection<PluginContexts>

Result:

type AppContext = {
  auth: AuthContext
  db: DbContext
}

Public types should be readable even if internal types are highly compositional.


12. Use distributive conditional types deliberately

This distributes over unions:

type NonEmptyObject<T> =
  T extends any
    ? {} extends T
      ? never
      : T
    : never

For:

type X = NonEmptyObject<{} | { id: string } | { name: string }>

You get:

type X = { id: string } | { name: string }

Distributive conditionals are powerful for filtering and transforming unions, but they can hurt performance if overused.


13. Encode runtime boundaries in the type system

For client/server libraries, the type system should model what can actually cross the boundary.

type JSONValue =
  | string
  | number
  | boolean
  | null
  | JSONValue[]
  | { [key: string]: JSONValue }

type ValidateJSON<T> =
  T extends (...args: any[]) => any
    ? 'Function is not serializable'
    : T extends JSONValue
      ? T
      : T extends object
        ? { [K in keyof T]: ValidateJSON<T[K]> }
        : T

This catches mistakes like returning a class instance, Response, function, database client, or request object from a server function.

Type safety is incomplete unless transport/runtime constraints are represented.


14. Beware server type vs client runtime mismatches

A value can be typed as this on the server:

type User = {
  createdAt: Date
}

But arrive like this on the client:

type SerializedUser = {
  createdAt: string
}

A library should be explicit about whether inference means:

InferServerOutput<T>

or:

InferClientOutput<T>

Better:

type InferWireOutput<T> = Serialize<InferServerOutput<T>>

Name your helpers according to the boundary they represent. Output is ambiguous in networked libraries.


15. Keep server-only types out of published client types

A common failure mode:

export type AppRouter = typeof appRouter

accidentally exposes:

DbClient
AuthService
InternalContext
SecretConfig

Better pattern:

type PublicRouterShape<TRouter> = {
  procedures: InferPublicProcedures<TRouter>
  transformer: InferTransformer<TRouter>
  errorShape: InferErrorShape<TRouter>
}

Create narrow “client-relevant” type surfaces instead of exporting the entire internal graph.


Core Pattern

// 1. User creates a runtime value.
const thing = createThing(...)

// 2. The value secretly carries a rich type graph.
type Thing = typeof thing

// 3. The library exposes projections.
type Input = InferInput<Thing>
type Output = InferOutput<Thing>
type Context = InferContext<Thing>

// 4. Public APIs validate user objects against that graph.
useThing({
  // autocomplete + type errors come from the projected graph
})