decoders

repository·main·Indexed 19 days ago

https://github.com/nvie/decoders

An elegant, battle-tested validation library for TypeScript that provides type-safe input data validation. It allows developers to define the expected shape of untrusted runtime data and verify it using a composable API with primitives like number, string, array, object, and isoDate. The library supports multiple decoding strategies (.verify, .value, .decode) and provides specialized tools for tagged unions, conditional decoding via select, and detailed error formatting.

Tokens
22.7K
Snippets
123
Records
133
Agent score
66%

What's inside decoders

  1. Compare `object`, `exact`, and `inexact` decoders

    main

    The three decoders in the object family differ only in how they handle extra properties on the input value:

    DecoderExtra Fields BehaviorOutput Content
    objectIgnoredOnly specified fields
    exactRejected (Validation Error)N/A
    inexactPassed throughSpecified fields + extra fields (as unknown)
  2. Use .decode() instead of calling decoders as functions

    main

    In v1, decoders were functions. In v2, decoders are objects that provide a .decode() method to process data.

    // ❌ 1.x
    const result = mydecoder(externalData);
    
    // ✅ 2.x
    const result = mydecoder.decode(externalData);
  3. Best practices for building decoders

    main

    When designing decoders for your application, follow these architectural principles:

    1. Name decoders after Data Types, not Fields

    Avoid naming a decoder after the property it validates (e.g., labelsDecoder). Instead, name it after the shape of the data it describes (e.g., commaSeparated). This makes the decoder reusable across different parts of your schema.

    2. Keep edge cases outside decoders

    To maintain composability, keep decoders focused on a single responsibility. Instead of building a decoder that handles both a specific format AND null values, build a decoder for the format and wrap it in nullable() or nullish() at the call site (inside your object() definition).

    Bad (Low Reusability): const messyString = string.transform(s => s.trim()).nullable(); (Harder to reuse the trim logic elsewhere)

    Good (High Reusability): const trimmedString = string.transform(s => s.trim()); const field = nullable(trimmedString);

  4. How to define a new decoder

    main

    To define a custom decoder, start with an existing decoder that accepts the desired input types, then use .refine() to narrow what is accepted or .transform() to change what is returned.

    • Narrowing acceptance: Use .refine() to add extra validation criteria.
    • Changing return type: Use .transform() to modify the value before it is returned.

    Understanding Acceptance vs. Return

    It is critical to distinguish between what a decoder accepts (input) and what it returns (output). The type Decoder<T> tells you what it returns, but not what it accepts.

    DecoderAcceptsReturnsType
    stringstringsstringsDecoder<string>
    isoDatestringsDate instancesDecoder<Date>
    urlstringsURL instancesDecoder<URL>
    truthyanythingbooleansDecoder<boolean>
    // Pattern for creating a custom decoder
    const myDecoder = existingDecoder
      .refine(value => /* validation logic */)
      .transform(value => /* transformation logic */);
  5. Use Decoder<T> to validate and transform data

    main

    The Decoder<T> class is the core abstraction used to validate and transform untrusted input data into typed values. It is reusable and composable.

    Decoding Strategies

    Choose a decoding method based on how you want to handle failures:

    MethodReturnsOn failureWhen to use?
    .verify(blob)TThrowsYou want to fail fast.
    .value(blob)T | undefinedReturns undefinedYou have sensible defaults.
    .decode(blob)DecodeResult<T>Returns DecodeResultYou need fine-grained error handling.

    DecodeResult Format

    When using .decode(), the result is a discriminated union:

    type DecodeResult<T> =
      | { ok: true; value: T }
      | { ok: false; error: Annotation };

    If ok is false, the error property contains an Annotation (a structured copy of the input with error details) which can be formatted using formatInline or formatShort.

    number.decode(3); // { ok: true, value: 3 }
    number.decode('hi'); // { ok: false, error: ... }
  6. Migrate from Decoders v1 to v2

    main

    Decoders v2 introduces breaking API changes to improve efficiency and simplicity. Decoders have transitioned from being functions to being class-like objects.

    Migration Checklist:

    1. Update installation: npm install decoders.
    2. Uninstall legacy dependencies if applicable: npm uninstall debrief or npm uninstall lemons.
    3. Stop calling decoders as functions: Use .decode(data) instead of decoder(data).
    4. Rename decoders: Many APIs have been renamed (e.g., map to .transform(), compose + predicate to .refine()).
    5. Replace Guards: The guard() function and Guard types are removed; use .verify() instead.
    6. Update URL decoders: The url() signature has changed; it now returns a URL instance instead of a string.
    7. Update custom decoders: If you used lemons or debrief, rewrite them using the .define() method.
    npm install decoders
    npm uninstall debrief
    npm uninstall lemons
  7. Customizing URL protocol validation

    main

    The httpsUrl decoder is a specialized version of url. If you need to validate URLs with different protocols (e.g., git:), use the .refine() method on the url decoder.

    import { url } from 'decoders';
    
    const gitUrl = url.refine(
      (value) => value.protocol === 'git:',
      'Must be a git:// URL',
    );
  8. Use decoders with TanStack Form

    main

    Because decoders implement the Standard Schema v1 interface, they can be used directly as validators in TanStack Form. You can pass a decoder to the validators property of a form.Field (e.g., using the onChange key) to handle field-level validation.

    import { useForm } from '@tanstack/react-form';
    import { nonEmptyString, positiveInteger } from 'decoders';
    
    function MyForm() {
      const form = useForm({
        defaultValues: { name: '', age: 0 },
        onSubmit: ({ value }) => console.log(value),
      });
    
      return (
        <form
          onSubmit={(e) => {
            e.preventDefault();
            form.handleSubmit();
          }}
        >
          <form.Field name="name" validators={{ onChange: nonEmptyString }}>
            {(field) => (
              <input
                value={field.state.value}
                onChange={(e) => field.handleChange(e.target.value)}
              />
            )}
          </form.Field>
          <form.Field name="age" validators={{ onChange: positiveInteger }}>
            {(field) => (
              <input
                type="number"
                value={field.state.value}
                onChange={(e) => field.handleChange(Number(e.target.value))}
              />
            )}
          </form.Field>
        </form>
      );
    }
  9. Avoid version conflicts and bundle bloat in monorepos

    main

    When using decoders within a shared package in a monorepo (e.g., a package containing shared validation schemas), do not list decoders as a regular dependency. Doing so can cause multiple copies of the library to be bundled, increasing size and causing runtime errors where decoder instances from one copy are not recognized by another.

    To fix this, declare decoders as a peer dependency in your shared package's package.json. This ensures the shared package uses the version of decoders provided by the host application. Using "*" as the version range is recommended to minimize compatibility issues.

    {
      "peerDependencies": {
        "decoders": "*"
      }
    }