typestar

schema.ts in TypeScript

A tiny schema validator where the parsed type is inferred from the schema.

type Validator<T> = {
  readonly _type?: T;
  parse(input: unknown, path?: string): T;
};

type Infer<V> = V extends Validator<infer T> ? T : never;

class ValidationError extends Error {
  constructor(readonly path: string, message: string) {
    super(`${path || "value"}: ${message}`);
    this.name = "ValidationError";
  }
}

const string = (): Validator<string> => ({
  parse(input, path = "") {
    if (typeof input !== "string") {
      throw new ValidationError(path, "expected a string");
    }
    return input;
  },
});

const number = (): Validator<number> => ({
  parse(input, path = "") {
    if (typeof input !== "number" || Number.isNaN(input)) {
      throw new ValidationError(path, "expected a number");
    }
    return input;
  },
});

const optional = <T>(inner: Validator<T>): Validator<T | undefined> => ({
  parse(input, path = "") {
    return input === undefined ? undefined : inner.parse(input, path);
  },
});

const array = <T>(inner: Validator<T>): Validator<T[]> => ({
  parse(input, path = "") {
    if (!Array.isArray(input)) {
      throw new ValidationError(path, "expected an array");
    }
    return input.map((item, i) => inner.parse(item, `${path}[${i}]`));
  },
});

const object = <Shape extends Record<string, Validator<unknown>>>(
  shape: Shape,
): Validator<{ [K in keyof Shape]: Infer<Shape[K]> }> => ({
  parse(input, path = "") {
    if (typeof input !== "object" || input === null) {
      throw new ValidationError(path, "expected an object");
    }
    const source = input as Record<string, unknown>;
    const out = {} as { [K in keyof Shape]: Infer<Shape[K]> };
    for (const key of Object.keys(shape) as (keyof Shape & string)[]) {
      out[key] = shape[key]!.parse(
        source[key],
        path ? `${path}.${key}` : key,
      ) as Infer<Shape[typeof key]>;
    }
    return out;
  },
});

const runSchema = object({
  lang: string(),
  tpm: number(),
  stars: optional(number()),
  tags: array(string()),
});

type Run = Infer<typeof runSchema>;

const parsed: Run = runSchema.parse({
  lang: "ts",
  tpm: 98,
  tags: ["types", "generics"],
});
console.log(parsed.lang.toUpperCase(), parsed.tags.length, parsed.stars);

try {
  runSchema.parse({ lang: "ts", tpm: "fast", tags: [] });
} catch (error) {
  console.log(error instanceof ValidationError, (error as Error).message);
}

How it works

  1. Each validator carries the type it produces in a phantom field.
  2. Infer<typeof schema> reads that type back out.
  3. One definition serves both the run-time check and the static type.

Keywords and builtins used here

The run, in numbers

Lines
87
Characters to type
2169
Tokens
658
Three-star pace
110 tpm

At the three-star pace of 110 tokens a minute, this run takes about 359 seconds.

Type this snippet

Step 1 of 1 in Encore, step 19 of 19 in Classes & patterns.

← Previous