Skip to Content
React Native14. Qualité14.1 TypeScript strict

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 --noEmit entre 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èreMenaceLe garde (déjà en place)
Réseau (JSON GitHub)Schéma inattenduzod parse/safeParse (ch. 7.3)
Params de route / deep linksURL forgée, types stringLecture défensive + defaults (ch. 5.3)
AsyncStorage (persist)Données d’une vieille versionversion + migrate (ch. 6.4)
SQLiteColonnes/nullTypes de retour explicites + alias (ch. 8.1)
Notifications (data)Payload arbitrairetypeof 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 / build

Pourquoi 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 huskynpx 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.