Chapitre 14.1 — TypeScript strict en React Native
Où on en est : le cours a écrit du TS strict depuis le début — ce chapitre consolide : quels réglages, quelles frontières, et les patterns TS spécifiques au mobile qu’on a croisés sans les nommer.
⏱️ TL;DR — La base Expo est déjà stricte ; on ajoute
noUncheckedIndexedAccess(le réglage qui attrape les vrais bugs de tableaux) et on nomme la doctrine : types stricts à l’intérieur, validation aux frontières (zod au réseau — ch. 7.3, params de routes — ch. 5.3, defaults aux réglages). Les typed routes d’Expo Router et les unions discriminées (ch. 6.3) sont vos deux armes mobiles spécifiques.pnpm tsc --noEmitentre dans le rituel de release.
🎯 Objectifs
- Régler tsconfig au niveau optimal (et savoir ce que chaque cran attrape).
- Nommer la doctrine des frontières (où on valide, où on fait confiance).
- Exploiter à fond typed routes et unions discriminées.
- Intégrer le type-check au rituel (pré-OTA, pré-release).
Le tsconfig de croisière
// tsconfig.json
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true, // déjà le défaut Expo — la fondation
"noUncheckedIndexedAccess": true, // ← L'AJOUT qui paie
"noUnusedLocals": true,
"noUnusedParameters": true
}
}noUncheckedIndexedAccess mérite son paragraphe : questions[index] devient
Question | undefined — et TS FORCE la garde. Or quel est le bug le plus plausible de
Drill ? Un index qui dépasse après un next() de trop, un tableau vide au premier
render… Exactement les cas que ce flag rend impossibles à ignorer. Le coût : quelques ?.
et gardes explicites — qui documentent le code au passage.
La doctrine des frontières
Le TS ne « prouve » que ce qui reste dans son monde. Tout ce qui ENTRE doit être validé — et le cours l’a fait partout sans le théoriser :
| Frontière | Menace | Le garde (déjà en place) |
|---|---|---|
| Réseau (JSON GitHub) | Schéma inattendu | zod parse/safeParse (ch. 7.3) |
| Params de route / deep links | URL forgée, types string | Lecture défensive + defaults (ch. 5.3) |
| AsyncStorage (persist) | Données d’une vieille version | version + migrate (ch. 6.4) |
| SQLite | Colonnes/null | Types de retour explicites + alias (ch. 8.1) |
| Notifications (data) | Payload arbitraire | typeof url === 'string' (ch. 9.2) |
La formule à retenir : « parse, don’t validate » — on transforme l’inconnu en type sûr
UNE fois, à l’entrée (le z.infer de zod EST le type interne) ; à l’intérieur, plus aucun
as, plus aucun doute.
Corollaire de style : as est un aveu d’échec (on affirme sans preuve). Les rares légitimes :
as const (précision), et les bords de libs mal typées — avec un commentaire. Un as qui
fait taire une erreur est un bug reporté à plus tard.
Les deux armes spécifiques au mobile
1. Typed routes (ch. 5.3) — activées depuis le début. Le gain méconnu : la refactorisation.
Renommez app/quiz.tsx en app/game.tsx : chaque href et router.push de l’app devient
une erreur de compilation — la liste EXHAUSTIVE des endroits à corriger. Sur le web sans ça :
grep et prières.
2. Unions discriminées (ch. 6.3) — la Phase de la machine d’état. Le pattern générique
à réutiliser partout où un « état » a des données différentes selon le cas :
// Le pattern, nommé : chaque variante porte SES données, le switch est exhaustif
type FetchState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: HttpError }
function render(state: FetchState<Question[]>) {
switch (state.status) {
case 'idle': …
case 'loading': …
case 'success': return state.data.length // data n'existe QUE ici
case 'error': return state.error.status // error n'existe QUE ici
default: {
const _exhaustive: never = state // ← ajout d'un cas → erreur ICI
return _exhaustive
}
}
}Le never final est le verrou d’exhaustivité (l’exercice 3 du ch. 6.3 en action) : toute
variante ajoutée casse la compilation à chaque switch — la spec vit dans les types.
Le rituel
pnpm tsc --noEmit # ~2-5 s sur Drill — AVANT chaque eas update / buildPourquoi c’est non-négociable en RN : Metro ne type-check pas (il transpile en ignorant
les erreurs de types !). Une app peut tourner en dev avec 50 erreurs TS. Le tsc --noEmit
est donc le SEUL moment où les types sont vérifiés — dans le rituel de release (ch. 12.6),
et idéalement en pre-commit (husky) ou CI.
⚠️ Piège — Le faux sentiment de sécurité : « ça compile dans VS Code » ≠ « tsc passe » (l’éditeur ne montre que les fichiers ouverts). Et son jumeau : « Metro accepte » ≠ « les types sont bons » (Metro s’en moque). La commande complète, sur tout le projet, est la seule vérité — 5 secondes, zéro excuse.
💡 Pour un dev Next.js — Vous connaissez tout ça — les spécificités mobiles à retenir : Metro ne vérifie RIEN (Next.js type-check au build, lui) ; les frontières sont plus nombreuses (deep links, notifications, storage versionné) ; et les unions discriminées y sont encore plus rentables (machines d’état de jeu, phases de sync). Le niveau de rigueur est le même — les endroits où il s’applique changent.
✏️ Exercices
1. Activez noUncheckedIndexedAccess sur Drill et corrigez les erreurs qui apparaissent.
Combien sont de VRAIS bugs potentiels vs du bruit ?
✅ Solution
Attendus : questions[s.index] (session — vrai risque si index dérape : garde ou assertion
documentée), choices[i] (borné par le map — bruit, mais le ?. est gratuit),
themes[0] éventuels (vrai risque : tableau vide au premier render). Ratio typique : 1/3 de
vrais risques — largement rentable pour un flag. Les « bruits » corrigés documentent quand
même l’invariant.
2. Écrivez le type SessionSummary (utilisé depuis la P6 sans définition canonique) en
dérivant tout ce qui peut l’être des types existants (Question, Phase…) au lieu de dupliquer.
✅ Solution
import type { Question } from './schemas' // z.infer — la source unique (ch. 7.3)
export type AnswerRecord = {
questionId: Question['id'] // ← dérivé, pas retapé
correct: boolean
responseMs: number
}
export type SessionSummary = {
theme: string
themeTitle: string
score: number
total: number
correctCount: number
bestStreak: number
isNewRecord: boolean
answers: AnswerRecord[]
}Question['id'] : si l’id devient un type brandé demain, tout suit. La règle : les types se
COMPOSENT depuis les sources (schémas zod, stores), jamais en parallèle.
3. Ajoutez le script “check” au package.json (tsc + lint) et branchez-le en pre-commit avec husky — le garde-fou automatique.
✅ Solution
"scripts": { "check": "tsc --noEmit && expo lint" }pnpm add -D husky → npx husky init → .husky/pre-commit : pnpm check. Désormais un
commit avec une erreur de type est refusé localement — le rituel devient mécanique. (En solo,
le pre-commit suffit ; la CI GitHub Actions est le cran au-dessus si le repo est partagé.)
🧠 Quiz
1. Que change noUncheckedIndexedAccess, et pourquoi est-il rentable dans Drill ?
Réponse
Tout accès indexé retourne T | undefined → gardes forcées. Les bugs d’index de session
(tableaux de questions, choix) deviennent des erreurs de compilation.
2. Énoncez la doctrine des frontières.
Réponse
Valider à l’entrée, faire confiance à l’intérieur (« parse, don’t validate ») : zod au
réseau, defaults aux params de routes, migrations au storage — puis plus aucun as dedans.
3. Pourquoi tsc —noEmit est-il indispensable spécifiquement en RN ?
Réponse
Metro ne type-check pas (transpilation seule) : l’app tourne avec des erreurs de types. tsc est le seul contrôle — à intégrer au rituel de release/pre-commit.
4. Quel gain caché offrent les typed routes ?
Réponse
La refactorisation sûre : renommer/déplacer un écran fait apparaître chaque href/push à corriger comme erreur de compilation — l’exhaustivité que grep ne garantit pas.
5. À quoi sert le never en fin de switch ?
Réponse
Le verrou d’exhaustivité : si une variante de l’union n’est pas traitée, l’affectation à never échoue à la compilation — ajouter un cas force la mise à jour de tous les switch.
👉 Chapitre suivant : 14.2 — Jest & la logique pure — le dividende de lib/ : tester le cœur du jeu en minutes.