NoInfer: controlando inferência em generics complexos

1. Entendendo o Problema: Quando a Inferência Automática Falha

O TypeScript possui um poderoso sistema de inferência de tipos, mas em cenários com generics complexos, essa inferência pode se tornar excessiva e produzir resultados indesejados. Um exemplo clássico ocorre quando usamos Array.filter com uniões de tipos:

type Status = "active" | "inactive" | "pending";

const statuses: Status[] = ["active", "inactive", "pending"];

// Problema: TypeScript infere o tipo mais amplo
const filtered = statuses.filter((s) => s !== "pending");
// Tipo inferido: Status[] (perdeu a informação de que "pending" foi excluído)

Outro cenário comum é em funções de validação de formulários, onde o objeto de validação pode poluir o tipo inferido:

function createValidator<T>(schema: Record<keyof T, (value: any) => boolean>) {
  return schema;
}

interface User {
  name: string;
  age: number;
}

// Inferência excessiva: o tipo T se torna muito amplo
const validator = createValidator({
  name: (v) => typeof v === "string",
  age: (v) => typeof v === "number",
  extraField: (v) => true, // Isso expande T indevidamente
});

2. O que é o Utilitário NoInfer<T>?

Introduzido no TypeScript 5.4, NoInfer<T> é um tipo utilitário que "esconde" um tipo da inferência do compilador. Diferente de Exclude (que remove tipos) ou Extract (que extrai tipos), NoInfer apenas torna o tipo invisível para o algoritmo de inferência, sem alterar sua estrutura.

Definição oficial simplificada:

type NoInfer<T> = [T][T extends any ? 0 : never];

Isso cria um tipo que o compilador não usa para deduzir o tipo genérico, mas que ainda é verificado em tempo de compilação.

3. Sintaxe e Uso Básico do NoInfer

Vamos ver um exemplo fundamental que demonstra o poder do NoInfer:

// Sem NoInfer - inferência problemática
function createConfig<T>(defaultValue: T, options: T[]) {
  return { defaultValue, options };
}

const config1 = createConfig("hello", ["world", 42]); 
// T inferido como string | number (muito amplo)

// Com NoInfer - controle da inferência
function createConfigFixed<T>(
  defaultValue: T, 
  options: NoInfer<T>[]
) {
  return { defaultValue, options };
}

const config2 = createConfigFixed("hello", ["world", "typescript"]);
// T corretamente inferido como string
// Erro se tentar: createConfigFixed("hello", ["world", 42]);

A diferença é clara: com NoInfer, apenas o defaultValue guia a inferência, enquanto as options são verificadas para serem compatíveis, mas não influenciam o tipo genérico.

4. Casos de Uso Práticos em Generics Complexos

Funções de validação com lookup

interface ValidationRules {
  required?: boolean;
  minLength?: number;
  maxLength?: number;
}

function createFieldValidator<T extends Record<string, any>>(
  fields: T,
  rules: { [K in keyof T]?: NoInfer<ValidationRules> }
) {
  return { fields, rules };
}

const userValidator = createFieldValidator(
  { name: "John", email: "john@example.com" },
  { name: { required: true, minLength: 2 } }
  // rules não expande o tipo T
);

Reducers com estado inicial

type Action = 
  | { type: "ADD"; payload: string }
  | { type: "REMOVE"; payload: number }
  | { type: "CLEAR" };

function createReducer<TState>(
  initialState: TState,
  reducer: (state: TState, action: NoInfer<Action>) => TState
) {
  return { initialState, reducer };
}

interface AppState {
  items: string[];
  count: number;
}

const reducer = createReducer(
  { items: [], count: 0 },
  (state, action) => {
    switch (action.type) {
      case "ADD":
        return { ...state, items: [...state.items, action.payload] };
      case "REMOVE":
        return { ...state, items: state.items.filter((_, i) => i !== action.payload) };
      case "CLEAR":
        return { ...state, items: [] };
    }
  }
);

APIs de configuração

type LogLevel = "debug" | "info" | "warn" | "error";

interface LoggerConfig {
  level: LogLevel;
  prefix?: string;
  transports?: string[];
}

function createLogger<T extends LoggerConfig>(config: T) {
  return {
    log: (message: string) => console.log(`[${config.level}] ${message}`),
    config,
  };
}

// Sem NoInfer, o tipo T pode ser expandido
const logger = createLogger({
  level: "info",
  prefix: "App",
  extraField: true, // Isso expande T indevidamente
});

// Com NoInfer
function createLoggerFixed<T extends LoggerConfig>(
  config: NoInfer<T> & LoggerConfig
) {
  return {
    log: (message: string) => console.log(`[${config.level}] ${message}`),
    config,
  };
}

5. Combinando NoInfer com Outros Tipos Utilitários

NoInfer + Partial

function createPartialConfig<T>(
  base: T,
  overrides: Partial<NoInfer<T>>
): T {
  return { ...base, ...overrides };
}

const config = createPartialConfig(
  { host: "localhost", port: 3000, ssl: true },
  { port: 8080 } // Apenas propriedades de T são permitidas
);

NoInfer + keyof

function getProperty<T, K extends keyof T>(
  obj: T,
  key: K
): T[K] {
  return obj[key];
}

function getPropertySafe<T, K extends keyof NoInfer<T>>(
  obj: T,
  key: K
): T[K] {
  return obj[key];
}

const data = { name: "John", age: 30 };
getPropertySafe(data, "name"); // OK

NoInfer + Conditional Types

type IsString<T> = T extends string ? "yes" : "no";

function processValue<T>(
  value: T,
  handler: (v: NoInfer<T>) => IsString<NoInfer<T>>
) {
  return handler(value);
}

const result = processValue("hello", (v) => "yes"); // Tipo: "yes"

6. Limitações e Boas Práticas

Onde NoInfer não funciona:

// Não funciona bem com inferência contextual em callbacks
function createHandler<T>(callback: (value: NoInfer<T>) => void) {
  return callback;
}

const handler = createHandler((value) => {
  // value: unknown - inferência contextual falha
});

Boas práticas:

  1. Use NoInfer apenas quando necessário para controlar inferência excessiva
  2. Prefira as const para literais quando possível
  3. Considere satisfies para validação sem inferência
  4. Evite usar NoInfer em parâmetros que dependem de inferência contextual

7. Exemplo Completo: Tipando uma API de Filtros com NoInfer

type FilterOperator = "equals" | "contains" | "greaterThan" | "lessThan";

interface FilterConfig<T> {
  field: keyof T;
  operator: FilterOperator;
  value: any;
}

function createFilter<T extends Record<string, any>>(
  schema: T,
  filters: NoInfer<FilterConfig<T>>[]
) {
  return {
    schema,
    filters,
    apply: (data: T[]) => {
      return data.filter((item) => {
        return filters.every((filter) => {
          const value = item[filter.field];
          switch (filter.operator) {
            case "equals":
              return value === filter.value;
            case "contains":
              return String(value).includes(String(filter.value));
            case "greaterThan":
              return Number(value) > Number(filter.value);
            case "lessThan":
              return Number(value) < Number(filter.value);
            default:
              return true;
          }
        });
      });
    },
  };
}

// Uso correto
interface Product {
  id: number;
  name: string;
  price: number;
  category: string;
}

const productFilter = createFilter(
  { id: 1, name: "Product", price: 100, category: "Electronics" },
  [
    { field: "price", operator: "greaterThan", value: 50 },
    { field: "category", operator: "equals", value: "Electronics" },
  ]
);

// Teste de tipos
const products: Product[] = [
  { id: 1, name: "Laptop", price: 1200, category: "Electronics" },
  { id: 2, name: "Mouse", price: 30, category: "Electronics" },
];

const filtered = productFilter.apply(products);
// filtered: Product[] - tipo preservado corretamente

// Erro de compilação se tentar campo inválido
// productFilter.apply([
//   { id: 3, name: "Book", price: 20, category: "Books", invalidField: true }
// ]);

Referências