Um problema extremamente comum que enfrentamos com TypeScript é a tipagem de arquivos externos, o principal desses problemas é quando temos que tipar coisas que vem do sistema onde estamos executando a aplicação, por exemplo, o process.env.

Eu já vi várias técnicas para tipar e até mesmo converter esses valores, mas a grande maioria delas tem falhas fundamentais, vou apresentar algumas aqui e quais são as que eu prefiro utilizar.

O problema

Quando temos variáveis de ambiente estamos lidando com uma das principais causas de valores desconhecidos possíveis no TypeScript. Primeiro, estamos lidando com um valor externo que pode ou não existir, então ele não vai ter um autocomplete ou intellisense quando você digita:

process.env.
//         ^ Não temos autocomplete aqui

Depois, mesmo se a gente tiver uma variável válida, por exemplo, a porta de um servidor:

process.env.PORT

O TypeScript não tem como saber como tipar a variável porque ela pode ser indefinida, então, corretamente, ele tipa tudo que vem do process.env como string | undefined, o que transforma o uso dessas variáveis em um pesadelo quando a gente tem que passar para funções:

function foo (x: string) {
  return x.toLowerCase()
}


foo(process.env.UMA_STRING) // erro

As soluções

Vamos à algumas soluções possíveis, vou deixar algumas opções aqui e comentar sobre elas no final de cada sessão.

Vem aprender comigo!

Quer aprender mais sobre criptografia e boas práticas com #TypeScript?

Se inscreva na Formação TS!

Type Augmentation

Extensão de tipos é um tipo de técnica mais avançada do TypeScript que serve bastante quando você está lidando com módulos que não possuem nenhuma tipagem e/ou são externos ao seu sistema. Ou seja, você pode essencialmente dizer para o TypeScript quais são os tipos que você quer para um módulo que você já tem instalado, mas que você não é o dono.

Por exemplo, podemos pedir para o TypeScript sobrescrever o objeto global do Node.js adicionando a tipagem correta para as nossas envs. Então podemos fazer assim:

// envs.d.ts
namespace NodeJS {
  interface ProcessEnv {
    PORT: string
  }
}

O que você está essencialmente fazendo é utilizando um conceito chamado declaration merging - que eu explico lá na Formação TS - para poder mesclar os dois objetos juntos e sobrescrever a tipagem natural do TS pela sua.

Essa proposta é bem útil para os seguintes casos:

  • Aplicações pequenas
  • Poucas variáveis de ambiente
  • Não é necessário que elas tenham um tipo diferente de string

Porém ela tem problemas críticos:

  • Se a variável só puder ser de um tipo específico, você não está convertendo
  • Não garante que a variável exista no sistema
  • Checagem somente em compile time

Além disso, usar arquivos de declaração para poder sobrescrever objetos globais não é necessariamente considerada uma boa prática.

Objeto de conversão

Outra forma mais "manual" de fazer isso é criar um objeto simples e associar os valores para esse objeto, fazendo casting desses objetos manualmente:

const envs = {
  PORT: process.env.PORT as string
}

Essa aproximação tem ainda mais problemas do que a anterior, porque:

  • Você está forçando a conversão do tipo, ou seja, para o seu código, a env sempre existe
  • Você não tem como dar um erro quando variáveis obrigatórias estão faltando sem escrever mais código
  • Não garante nem em runtime e nem em compile time

Use uma lib de validação (Zod)

Esse é meu método preferido, durante a pesquisa para esse artigo eu achei esse outro artigo que fala sobre uma ferramenta chamada t3-env, essencialmente você poderia usar ela mais ou menos assim (exemplo do outro artigo):

import { createEnv } from "@t3-oss/env-core";
import { z } from "zod";

export const env = createEnv({
  server: {
    DATABASE_URL: z.string().url(),
    OPEN_AI_API_KEY: z.string().min(1),
  },
  clientPrefix: "PUBLIC_",
  client: {
    PUBLIC_CLERK_PUBLISHABLE_KEY: z.string().min(1),
  },
  runtimeEnv: process.env,
});

Enquanto é uma forma bem interessante de criar uma validação que funciona tanto em runtime quanto em compile time, eu não vejo sentido em ter esse pacote, já que você pode usar só o Zod para fazer tudo isso de forma muito mais simples e muito mais limpa.

O caso do T3 é uma variação porque ele também faz a tipagem para variáveis de cliente, o que eu, pessoalmente, não gosto muito. Prefiro manter as duas coisas separadas.

Tendo somente o Zod instalado como pacote, você pode criar um arquivo chamado config.ts, nele você pode não só ter todas as suas variáveis de ambiente mas também qualquer outra configuração que você possa passar para a sua app:

import { z } from 'zod'

const appConfigSchema = z.object({
  PORT: z.coerce.number().min(1024).max(65535).default(3000),
  DATABASE_HOST: z.string(),
  DATABASE_USER: z.string(),
  MAIN_EMAIL: z.string().email(),
  MAIN_ACCOUNT_ID: z.string().uuid().optional()
})

export type AppConfig = z.infer<typeof appConfig>
export const appConfig = appConfigSchema.parse(process.env)

E depois é só utilizar na sua aplicação, de qualquer lugar:

import { appConfig } from '../config.ts'

console.log(appConfig.PORT) // number entre 1024 e 65535, padrão 3000

Essa checagem não só vai garantir que as variáveis existam no seu sistema, porque caso contrário o Zod vai lançar um erro de valor nulo, e também garante que os tipos em runtime vão ser tipos válidos dentro do seu schema.

Uma outra opção um pouco mais "pronta" é utilizar o znv, que faz exatamente a mesma coisa, só que tem uma listagem de erros um pouco mais bonita.