Migrando uma base de código grande de Flow para TypeScript
Compare migrações de Flow para TypeScript em massa e incrementais, com dicas sobre codemods, verificadores simultâneos, conversão de tipos e progresso.
Para migrar uma base de código grande de Flow para TypeScript, escolha entre duas estratégias antes de mexer em qualquer código. A primeira é executar um único codemod em todo o repositório, o que funciona bem para código que já está totalmente tipado com Flow e que aguenta um congelamento curto. A segunda é uma conversão incremental, começando pelas folhas, que mantém os dois verificadores de tipos rodando por meses ou anos.
Toda atualização do Flow demora demais, as libdefs de que você precisa estão faltando ou desatualizadas, e os novos contratados vivem perguntando por que o repositório não está em TypeScript. Ainda assim, parar o desenvolvimento de features por um trimestre está fora de cogitação.
Este guia compara as duas estratégias, explica como Flow e TypeScript coexistem durante uma migração incremental, mapeia os tipos exclusivos do Flow que os codemods não conseguem converter de forma limpa e descreve como evitar que o esforço empaque.
Principais conclusões
- O Pinterest converteu 3,7 milhões de linhas de Flow para TypeScript em oito meses com uma única execução de codemod e confirmou que a saída do Babel dos dois sistemas de tipos era idêntica byte a byte.
- O Yelp migrou 1,4 milhão de linhas de código-fonte, pacote por pacote, ao longo de três anos e sete meses, elevando a cobertura de tipos de 83,15% no Flow para 96,44% no TypeScript.
- O TypeScript não tem tipos de objeto exatos. Ele rejeita propriedades extras apenas quando um objeto literal é atribuído diretamente a um destino tipado.
- O
opaque typedo Flow vira um branded type no TypeScript, e$Shape<T>viraPartial<T>, que o próprio Flow agora recomenda. - Migrações incrementais empacam depois que os times mais engajados terminam. Ordenar os módulos restantes por quantos outros eles desbloqueiam e definir prazos com data mantém o ritmo.
Por que os times ainda migram de Flow para TypeScript
Os times abandonam o Flow porque o ecossistema em torno dele encolheu. Tipos TypeScript para pacotes de terceiros são fáceis de encontrar, o ferramental pressupõe TypeScript e cada ano a mais no Flow aumenta o custo da migração inevitável. Em maio de 2021, o time do Flow anunciou que priorizaria a base de código interna do Facebook em detrimento da comunidade open source. O Yelp citou esse anúncio como fator decisivo, junto com o catálogo muito maior do DefinitelyTyped em comparação com o flow-typed e o número crescente de pacotes que já trazem suas próprias declarações TypeScript.
O custo de ficar também aparece no ferramental. Depois de sair do Flow, o Yelp conseguiu adotar o swc e o @typescript-eslint. Algumas bases de código em Flow também usam PropTypes. Essa é uma camada à parte: PropTypes validam props em tempo de execução, enquanto Flow e TypeScript verificam tipos em tempo de build. Por isso, decida sobre PropTypes separadamente da migração do verificador de tipos.
Codemod big-bang ou incremental: qual estratégia faz sentido?
Uma migração big-bang via codemod é adequada para uma base de código que já está totalmente tipada com Flow e tolera um congelamento curto. Uma migração incremental é adequada para um monorepo em que o desenvolvimento de features não pode parar, e o preço é manter dois verificadores de tipos rodando lado a lado por meses ou anos.
| Codemod big-bang | Incremental | |
|---|---|---|
| Pré-requisito | Cobertura total de Flow | Pacotes com dependências bem delimitadas |
| Congelamento | Curto, no cutover | Nenhum |
| Tempo de calendário | Meses de preparação | Anos |
| Pico de esforço | Script de automação e validação | Ferramental de interoperabilidade e engajamento dos times |
| Onde o risco se concentra | No dia do cutover | Na cauda longa |
| Falha típica | Quebra de build ou de caminhos no merge | Estagnação com a migração pela metade |
| Referência | Pinterest: 3,7 mi de linhas, oito meses | Yelp: 1,4 mi de SLoC, 3 anos e 7 meses |
O Pinterest atingiu primeiro 100% de cobertura de Flow, o que tornou viável um único commit. Em seguida, o time rodou o flow-to-typescript-codemod da Stripe, que funciona em duas etapas: um comando convert baseado em Babel reescreve a sintaxe Flow como TypeScript e, depois, um comando fix construído sobre o ts-morph corrige, suprime ou reporta os erros de TypeScript restantes. O Pinterest recriava o branch TypeScript a partir do master todos os dias, mais de 60 vezes até o cutover, e reuniu todas as correções manuais em um único commit aplicado via cherry-pick. A Factorial, em um post de fevereiro de 2022, descreveu um caminho híbrido: converteu primeiro uma área desacoplada para mostrar que Flow e TypeScript podiam rodar juntos em produção e, depois, converteu o restante de uma só vez com o flow-to-ts da Khan Academy.
As duas estratégias de migração de Flow para TypeScript podem usar a mesma verificação de equivalência. As anotações de tipo são removidas antes de o código rodar, então uma conversão correta produz o mesmo JavaScript. O Pinterest comparou com diff a saída do Babel das duas versões do código para comprovar isso. O que de fato quebra em produção geralmente vem de arquivos renomeados, globs atualizados e configuração de build. O Pinterest encontrou quase 100 referências hardcoded a .js. Revisar sessões de produção após cada cutover revela páginas que pararam de renderizar e erros de carregamento de chunks que as verificações estáticas não pegam.
Rodando Flow e TypeScript lado a lado
Em uma migração incremental, módulos TypeScript já convertidos nunca devem importar código Flow ainda não convertido. Por isso, a conversão começa pelas folhas do grafo de dependências e sobe a partir daí. O Yelp usou o flowgen para transformar a saída .d.ts de cada pacote convertido em tipos compatíveis com Flow para o código Flow que ainda o importava. O time não encontrou nenhuma ferramenta que fizesse o caminho inverso, gerando declarações TypeScript a partir de Flow, e por isso teve que começar pelos pacotes internos mais profundos. O Flow lê um arquivo .js.flow no lugar do módulo .js ao lado dele, o que faz desse arquivo o destino natural para a saída do flowgen. O flow-api-translator da Meta hoje consegue emitir definições TypeScript a partir de código Flow, mas seus autores o direcionam à publicação de bibliotecas que continuam em Flow, e não à migração para fora do Flow.
Mantenha os arquivos Flow não convertidos fora do programa TypeScript e deixe que apenas o Flow os verifique. allowJs e checkJs servem para JavaScript puro, não para arquivos com anotações Flow. Migre as regras de ESLint à medida que cada módulo é convertido (o Pinterest mapeou flowtype/no-weak-types para @typescript-eslint/no-explicit-any), seguindo uma configuração do typescript-eslint. Configure os editores para usar a versão do TypeScript do workspace.
Impeça que a superfície em Flow cresça com uma verificação na CI:
// scripts/no-new-flow.js
const { execSync } = require("node:child_process");
const { readFileSync } = require("node:fs");
const base = process.env.BASE_REF || "origin/main";
const added = execSync(`git diff --name-only --diff-filter=A ${base}...HEAD`, { encoding: "utf8" })
.split("\n")
.filter((f) => /\.(js|jsx)$/.test(f));
const offenders = added.filter((f) => /@flow\b/.test(readFileSync(f, "utf8")));
if (offenders.length) {
console.error("New Flow files are not allowed. Write these in TypeScript:\n" + offenders.join("\n"));
process.exit(1);
}
O script no-new-flow.js lista os arquivos que o branch adiciona em relação à ref base e faz o job falhar se algum deles contiver @flow. Ao renomear, use git mv src/foo.js src/foo.ts e faça o commit das renomeações separado das alterações de conteúdo, para que git log --follow preserve o histórico.
Tipos do Flow que não têm tradução direta
Quatro construções do Flow não têm equivalente direto no TypeScript: objetos exatos, sigilos de variância, tipos opacos e $Shape.
Tipos de objeto exatos
O TypeScript não tem tipos de objeto exatos. Um objeto exato do Flow vira um tipo de objeto comum, e o TypeScript rejeita propriedades extras apenas quando um objeto literal é atribuído diretamente a esse tipo. Os tipos de objeto do Flow são exatos por padrão desde a versão 0.202. {| |} é a sintaxe explícita mais antiga, e ... indica um objeto inexato.
// Flow
type User = { id: string, name: string }; // exact by default
type LooseUser = { id: string, name: string, ... };
// TypeScript
type User = { id: string; name: string };
const u: User = { id: "1", name: "Ada", role: "admin" }; // error: excess property
const extra = { id: "1", name: "Ada", role: "admin" };
const v: User = extra; // allowed: not a fresh literal
Variância
O sigilo de propriedade + do Flow corresponde ao readonly do TypeScript. O sigilo - (somente escrita) não tem equivalente no TypeScript. O TypeScript oferece, por outro lado, os modificadores in/out em parâmetros de tipos genéricos.
type Config = { readonly endpoint: string }; // Flow: { +endpoint: string }
type Sink = { value: number }; // Flow: { -value: number }; intent must be documented
Tipos opacos
O opaque type do Flow não tem palavra-chave equivalente no TypeScript. A substituição usual é um branded type: uma interseção do tipo base com uma propriedade marcadora única que só uma função construtora designada consegue produzir. Um tipo opaco do Flow é transparente dentro do arquivo em que é definido. Já um brand é aplicado em todos os lugares, então a construtora precisa de um cast.
declare const userIdBrand: unique symbol;
export type UserId = string & { readonly [userIdBrand]: true };
export function toUserId(s: string): UserId {
return s as UserId;
}
const bad: UserId = "u_1"; // error
$Shape
O Flow descontinuou o $Shape por ser inseguro e, desde o Flow 0.201, recomenda Partial<T>, que corresponde diretamente ao Partial<T> do TypeScript. Verifique qualquer ponto de chamada que dependesse de outros comportamentos do $Shape.
function update(patch: Partial<User>) { /* ... */ } // Flow: $Shape<User>
Os mapeamentos mais simples de tipos do Flow para o TypeScript são diretos:
| Flow | TypeScript |
|---|---|
?T | T | null | undefined |
mixed | unknown |
$ReadOnly<T> | Readonly<T> |
$Keys<T> | keyof T |
$ReadOnlyArray<T> | ReadonlyArray<T> |
React.Node | React.ReactNode |
Substituindo as libdefs do flow-typed
Depois que um pacote for convertido, exclua suas libdefs do flow-typed. Use os tipos que a dependência já inclui por meio do campo types do package.json, quando houver, e instale @types/* do DefinitelyTyped quando não houver. Libdefs escritas à mão para pacotes internos ou sem manutenção precisam ser reescritas como arquivos .d.ts. Comece essas cedo, porque elas bloqueiam todos os consumidores.
Medindo o progresso para a migração não empacar
Migrações incrementais empacam quando os times mais entusiasmados terminam. O que fez avançar os últimos cerca de 200 pacotes do Yelp foi ordenar os módulos Flow restantes por quantos outros eles desbloqueiam e publicar listas de conversão com datas. Acompanhe três contagens toda semana:
grep -rl --include='*.js' --include='*.jsx' '@flow' src | wc -l # Flow files left
find src \( -name '*.ts' -o -name '*.tsx' \) ! -name '*.d.ts' | wc -l # TS files
grep -ro '@ts-expect-error' src | wc -l # suppressions
Meça também a cobertura de tipos antes e depois. Pontue cada módulo bloqueado por quantos módulos Flow estão esperando por ele e leve o topo dessa lista aos times responsáveis. O Yelp definiu prazos trimestrais que culminavam em uma data de fim de vida. A data atrasou um trimestre, mas a pressão funcionou. Planeje um fallback de supressão automática para os últimos retardatários: cerca de 2.000 das aproximadamente 6.000 diretivas @ts-expect-error do Yelp vieram dessa etapa final.
Escala realista: meses, não uma sprint
Uma migração grande de Flow para TypeScript leva, no mínimo, meses. O caminho via codemod do Pinterest levou oito meses, e o caminho incremental do Yelp levou mais de três anos e meio. Ative as flags de strict gradualmente depois que a conversão estiver concluída. Para o compilador baseado em JavaScript (TypeScript 6.0 e anteriores), o Pinterest aumentou o limite de heap do Node para impedir que o tsc ficasse sem memória. O post não informa o valor usado, então considere o número abaixo apenas um exemplo:
NODE_OPTIONS=--max-old-space-size=8192 npx tsc --noEmit
Dimensione esse valor de acordo com a sua máquina. Mesmo assim, o Pinterest relatou verificações de tipos completas de 3 a 4 minutos. O TypeScript 6.0 foi a última versão baseada em JavaScript, e é com o compilador nativo do TypeScript 7 que a velocidade de verificação de tipos em repositórios muito grandes muda de patamar. Seu próximo passo é contar seus arquivos @flow, verificar se a cobertura está em 100% e escolher uma estratégia com base nesse número.
Perguntas frequentes
Os comentários $FlowFixMe devem virar @ts-expect-error ou @ts-ignore no TypeScript?
Use @ts-expect-error. As duas diretivas suprimem o erro da linha seguinte, mas @ts-expect-error, adicionada no TypeScript 3.9, reporta o erro 'Unused @ts-expect-error directive' assim que essa linha deixa de falhar. Assim, supressões obsoletas aparecem à medida que os tipos melhoram, e a contagem de supressões só pode cair. Já @ts-ignore continua silenciosa mesmo depois que o problema de fundo é corrigido, então pode esconder novos erros na mesma linha.
O que substitui $PropertyType e $ElementType do Flow no TypeScript?
Indexed access types, que usam a mesma sintaxe no Flow e no TypeScript: $PropertyType<T, 'k'> vira T['k'] e $ElementType<T, K> vira T[K]. O Flow descontinuou os dois utilitários na versão 0.155 e os removeu na 0.266. Rodar o autofix use-indexed-access-type do eslint-plugin-fb-flow antes da conversão permite que os codemods levem esses tipos adiante sem alterações.
Trocar @babel/preset-flow por @babel/preset-typescript faz a verificação de tipos do código?
Não. O Babel remove os tipos TypeScript sem verificá-los, então a CI ainda precisa de uma etapa separada de tsc --noEmit. No Babel 8, a opção onlyRemoveTypeImports do preset-typescript tem true como padrão, então o Babel remove apenas os imports marcados com import type. Imports comuns de bindings usados só como tipo permanecem na saída e podem quebrar a resolução de módulos. Marque esses imports com import type ou defina onlyRemoveTypeImports como false para recuperar o comportamento do Babel 7.
Complete picture for complete understanding
Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.
Star on GitHub12k