Building a type-safe object builder in TypeScript

Published on 643 words 2 min read

Sometimes just an object isn't enough

Building a type-safe object builder in TypeScript

Sometimes you have to pass just an obscene amount of data to a constructor or you have a big, fat object that you have to send somewhere.

function builder<
  T extends Record<string, (arg: any) => NonNullable<unknown> | null>,
>(requirements: T) {
  type InputShape = {
    [K in keyof T]: Parameters<T[K]>[0];
  };

  type RequiredKeys = {
    [K in keyof InputShape]: IsInUnion<undefined, InputShape[K]> extends true
      ? never
      : K;
  }[keyof InputShape];

  type OptionalKeys = {
    [K in keyof InputShape]: IsInUnion<undefined, InputShape[K]> extends true
      ? K
      : never;
  }[keyof InputShape];

  type OutputShape = {
    [K in keyof T]: ReturnType<T[K]>;
  };

  return new (class LocalBuilder<
    THave extends Partial<OutputShape> = Partial<OutputShape>,
  > {
    private obj: THave;

    constructor(obj: THave) {
      this.obj = obj;
      return this;
    }

    public withOnly<
      TKey extends Exclude<keyof InputShape, keyof THave>,
      TValue extends InputShape[TKey],
    >(key: TKey, value: TValue) {
      return this.with(key, value);
    }

    public with<TKey extends keyof InputShape, TValue extends InputShape[TKey]>(
      key: TKey,
      value: TValue,
    ) {
      const fn = requirements[key];

      if (fn === undefined) {
        throw new ReferenceError(`No key ${String(key)} in object`);
      }

      (this.obj as unknown as InputShape)[key] = fn(value);

      type NewHave = Omit<THave, TKey> & Record<TKey, OutputShape[TKey]>;

      return this as unknown as LocalBuilder<
        NewHave extends Partial<OutputShape> ? NewHave : never
      >;
    }

    public build(
      this: THave extends Pick<OutputShape, RequiredKeys> ? this : never,
    ) {
      type UnsetKeys = Exclude<OptionalKeys, keyof THave>;
      type FinalObject = THave & {
        [K in UnsetKeys]: undefined;
      };
      return this.obj as Simplify<FinalObject>;
    }
  })<{}>({});
}

function optional<TArg, TRet, TFallback extends (() => TRet) | undefined>(
  fn: (arg: TArg) => TRet,
  fallback?: TFallback,
) {
  const newFn = (arg: TArg | undefined) => {
    if (arg === undefined) {
      if (fallback) {
        return fallback();
      } else {
        return null;
      }
    }

    return fn(arg);
  };

  type NewReturn = TFallback extends undefined ? TRet | null : TRet;

  return newFn as (arg: TArg | undefined) => NewReturn;
}

const bldr = builder({
  a: (x: string) => x,
  b: (x: string) => x,
  c: (x: string) => ({ x }),
  d: optional((x: number) => x),
  e: optional(
    (x: number) => BigInt(x * 2),
    () => 0n,
  ),
});

const k = bldr
  .with("b", "b val")
  .with("a", "a val")
  .with("c", "c val")
  .with("d", 4)
  .with("e", 3)
  .build();

assert.deepStrictEqual(k, {
  a: "a val",
  b: "b val",
  c: {
    x: "c val",
  },
  d: 4,
  e: 6n,
});