TypeScript en pratique - 10 - Generics contraints dans les libs

Comment écrire des APIs génériques reutilisables avec des contraintes, le builder pattern, les event emitters type-safe et les patterns utilises par Prisma, tRPC et Zod.

10 - Generics contraints dans les libs

Ce que tu vas apprendre

  • Comment contraindre les generics pour guider les consommateurs de ta lib
  • Le builder pattern avec inference progressive des types
  • Les event emitters type-safe
  • Le generic repository pattern
  • Comment Prisma, tRPC et Zod utilisent les generics contraints en interne

Prerequisites

Avoir lu l'article sur les decorateurs natifs et maîtriser les bases des generics TypeScript.


Pourquoi contraindre les generics

Un generic sans contrainte accepte tout :

typescriptfunction identity<T>(value: T): T {
  return value
}

C'est utile, mais quand tu ecris une lib, tu veux souvent guider l'utilisateur. Si ta fonction attend un objet avec un id, dis-le au système de types :

typescriptfunction findById<T extends { id: string }>(items: T[], id: string): T | undefined {
  return items.find(item => item.id === id)
}

// ✅ Compile
findById([{ id: "1", name: "Alice" }], "1")

// ❌ Erreur : { name: string } n'a pas de propriete 'id'
findById([{ name: "Alice" }], "1")

La contrainte extends { id: string } fait deux choses : elle restreint les types acceptes et elle donne acces a item.id dans le corps de la fonction.

Contraintes sur les clés

Le pattern le plus courant dans les libs est de contraindre un paramètre de type a etre une clé d'un autre type :

typescriptfunction pick<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
  const result = {} as Pick<T, K>
  for (const key of keys) {
    result[key] = obj[key]
  }
  return result
}

const user = { id: 1, name: "Alice", email: "a@b.com", age: 30 }
const summary = pick(user, ["name", "email"])
// type: { name: string; email: string }

TypeScript infere K à partir du tableau passe en argument. L'autocompletion propose "id" | "name" | "email" | "age".

Builder pattern avec generics

Le builder pattern est un classique pour les APIs fluentes. L'idee : chaque appel de méthode enrichit le type générique.

typescripttype QueryConfig<
  TTable extends string = string,
  TSelect extends string = string,
  TWhere extends Record<string, unknown> = Record<string, unknown>
> = {
  table: TTable
  select: TSelect[]
  where: TWhere
}

class QueryBuilder<TConfig extends Partial<QueryConfig> = {}> {
  private config: Partial<QueryConfig> = {}

  from<T extends string>(table: T): QueryBuilder<TConfig & { table: T }> {
    this.config.table = table
    return this as unknown as QueryBuilder<TConfig & { table: T }>
  }

  select<T extends string>(...fields: T[]): QueryBuilder<TConfig & { select: T[] }> {
    this.config.select = fields
    return this as unknown as QueryBuilder<TConfig & { select: T[] }>
  }

  where<T extends Record<string, unknown>>(conditions: T): QueryBuilder<TConfig & { where: T }> {
    this.config.where = conditions
    return this as unknown as QueryBuilder<TConfig & { where: T }>
  }

  build(): TConfig {
    return this.config as TConfig
  }
}

const query = new QueryBuilder()
  .from("users")
  .select("name", "email")
  .where({ active: true })
  .build()

// type de query : { table: "users"; select: ("name" | "email")[]; where: { active: boolean } }

Chaque appel retourne un QueryBuilder avec un type plus precis. C'est comme ca que des libs comme Prisma construisent leurs requêtes type-safe.

Event emitter type-safe

Un event emitter classique en JavaScript n'a aucun typage sur les événements. Avec les generics contraints, on peut faire beaucoup mieux :

typescripttype EventMap = Record<string, unknown[]>

class TypedEmitter<TEvents extends EventMap> {
  private listeners = new Map<string, Function[]>()

  on<K extends keyof TEvents & string>(
    event: K,
    handler: (...args: TEvents[K]) => void
  ): this {
    const existing = this.listeners.get(event) ?? []
    existing.push(handler)
    this.listeners.set(event, existing)
    return this
  }

  emit<K extends keyof TEvents & string>(
    event: K,
    ...args: TEvents[K]
  ): void {
    const handlers = this.listeners.get(event) ?? []
    for (const handler of handlers) {
      handler(...args)
    }
  }
}

// Definition des evenements
interface AppEvents {
  userCreated: [user: { id: string; name: string }]
  orderPlaced: [orderId: string, total: number]
  error: [error: Error]
}

const emitter = new TypedEmitter<AppEvents>()

// ✅ Autocompletion sur les noms d'evenements et les parametres
emitter.on("userCreated", (user) => {
  console.log(user.name) // type: { id: string; name: string }
})

emitter.on("orderPlaced", (orderId, total) => {
  console.log(`Commande ${orderId} : ${total}EUR`)
})

// ❌ Erreur : "unknown" n'est pas un evenement connu
emitter.emit("unknown", 42)

J'utilise ce pattern sur paltemps.fr pour les communications entre modules. Ca évité les erreurs de typo sur les noms d'événements et garantit que les handlers recoivent les bons paramètres.

Generic repository pattern

Le repository pattern avec generics donne une interface de base réutilisable :

typescriptinterface Entity {
  id: string
  createdAt: Date
  updatedAt: Date
}

interface Repository<T extends Entity> {
  findById(id: string): Promise<T | null>
  findMany(filter: Partial<Omit<T, keyof Entity>>): Promise<T[]>
  create(data: Omit<T, keyof Entity>): Promise<T>
  update(id: string, data: Partial<Omit<T, keyof Entity>>): Promise<T>
  delete(id: string): Promise<void>
}

interface User extends Entity {
  name: string
  email: string
  role: "admin" | "user"
}

class UserRepository implements Repository<User> {
  async findById(id: string): Promise<User | null> {
    // implementation...
    return null
  }

  async findMany(filter: Partial<{ name: string; email: string; role: "admin" | "user" }>): Promise<User[]> {
    // Le type de filter est derive automatiquement : pas de 'id', 'createdAt', 'updatedAt'
    return []
  }

  async create(data: { name: string; email: string; role: "admin" | "user" }): Promise<User> {
    // data n'a pas les champs Entity — ils sont generes
    return {} as User
  }

  async update(id: string, data: Partial<{ name: string; email: string; role: "admin" | "user" }>): Promise<User> {
    return {} as User
  }

  async delete(id: string): Promise<void> {}
}

Omit<T, keyof Entity> retire les champs de base (id, dates) pour que create et findMany ne les exposent pas.

Generic middleware pattern

Les systèmes de middleware/plugins utilisent les generics pour chainer les transformations :

typescripttype Middleware<TInput, TOutput> = (input: TInput) => TOutput

function compose<A, B>(m1: Middleware<A, B>): Middleware<A, B>
function compose<A, B, C>(m1: Middleware<A, B>, m2: Middleware<B, C>): Middleware<A, C>
function compose<A, B, C, D>(
  m1: Middleware<A, B>,
  m2: Middleware<B, C>,
  m3: Middleware<C, D>
): Middleware<A, D>
function compose(...middlewares: Function[]): Function {
  return (input: unknown) =>
    middlewares.reduce((acc, fn) => fn(acc), input)
}

const parseInput = (raw: string): { value: number } => ({ value: parseInt(raw) })
const validate = (data: { value: number }): { value: number; valid: boolean } => ({
  ...data,
  valid: data.value > 0
})
const format = (data: { value: number; valid: boolean }): string =>
  data.valid ? `OK: ${data.value}` : "INVALID"

const pipeline = compose(parseInput, validate, format)
// type: Middleware<string, string>

const result = pipeline("42") // "OK: 42"

Chaque middleware connecte son type de sortie au type d'entree du suivant. Si les types ne correspondent pas, TypeScript le signale.

Comment Prisma utilise les generics

Prisma généré des types à partir de ton schema. Quand tu ecris prisma.user.findMany({ where: { email: "..." } }), le type de where est contraint par le modèle User.

Le mecanisme simplifie :

typescript// Genere par Prisma
interface UserWhereInput {
  id?: string | StringFilter
  email?: string | StringFilter
  name?: string | StringFilter
  AND?: UserWhereInput[]
  OR?: UserWhereInput[]
  NOT?: UserWhereInput
}

interface UserDelegate {
  findMany(args?: { where?: UserWhereInput }): Promise<User[]>
  findUnique(args: { where: { id: string } | { email: string } }): Promise<User | null>
}

Le generic UserWhereInput est derive du schema Prisma. Il n'accepte que les champs définis dans le modèle. C'est de la génération de code, pas de la magie.

Comment tRPC chaîne les generics

tRPC utilise le builder pattern pour accumuler le contexte, l'input et l'output d'une route :

typescript// Simplifie pour illustrer le principe
const appRouter = router({
  getUser: publicProcedure
    .input(z.object({ id: z.string() }))    // T -> T & { input: { id: string } }
    .query(({ input }) => {                  // input est type { id: string }
      return db.user.findUnique({ where: { id: input.id } })
    })
})

Chaque appel (.input(), .query(), .mutation()) retourne un nouveau type qui porte les informations accumulees. Le generic grossit a chaque étape.

Piege : trop de generics

J'ai vu des libs avec des signatures comme celle-ci :

typescriptfunction process<
  T extends Record<string, unknown>,
  K extends keyof T,
  V extends T[K],
  R extends Record<K, V>
>(obj: T, key: K, value: V): R {
  // ...
}

Quand tu as plus de 3 paramètres de type, c'est un signe que ta fonction fait trop de choses ou que tu peux simplifier. Souvent, TypeScript peut inferer les types intermediaires.

Regle : si l'utilisateur de ta lib doit écrire les generics explicitement, c'est que l'inference n'est pas assez bonne.

Contraintes recursives

Pour les structures arborescentes, les generics recursifs sont utiles :

typescriptinterface TreeNode<T> {
  value: T
  children: TreeNode<T>[]
}

function findInTree<T>(
  node: TreeNode<T>,
  predicate: (value: T) => boolean
): T | undefined {
  if (predicate(node.value)) return node.value
  for (const child of node.children) {
    const found = findInTree(child, predicate)
    if (found !== undefined) return found
  }
  return undefined
}

const tree: TreeNode<{ id: string; label: string }> = {
  value: { id: "root", label: "Root" },
  children: [
    {
      value: { id: "a", label: "A" },
      children: []
    }
  ]
}

const node = findInTree(tree, v => v.id === "a")
// type: { id: string; label: string } | undefined

Résumé

  • extends dans un generic contraint les types acceptes et donne acces aux propriétés garanties
  • Le builder pattern avec generics permet des APIs fluentes ou le type s'enrichit a chaque appel
  • Les event emitters type-safe utilisent un EventMap pour lier les noms d'événements a leurs paramètres
  • Prisma, tRPC et Zod sont des exemples réels de generics contraints a grande échelle
  • Si l'utilisateur doit écrire les generics a la main, l'inference de ta lib est a ameliorer
  • Au-dela de 3 paramètres de type, simplifie

Article précédent : 09 - Decorateurs natifs

Article suivant : 11 - Déclaration files et packages types

Sources

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.