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é
extendsdans 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
EventMappour 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
- TypeScript Handbook - Generics par Microsoft
- Prisma Client API Référence par Prisma
- tRPC - Quickstart par Alex Johansson