Hone logo
Hone
Problems

Building a Basic Refinement Type System in TypeScript

Refinement types allow you to create types that are more specific than standard TypeScript types, adding runtime checks to ensure data conforms to certain conditions. This is incredibly useful for ensuring data integrity, validating user input, and creating more robust APIs. Your challenge is to implement a foundational system for defining and working with refinement types in TypeScript.

Problem Description

You need to create a system that allows developers to define "refinement types." A refinement type is essentially a base type (like number or string) with an added condition (a predicate function) that must be true for a value to be considered of that refinement type.

Your system should support:

  1. Defining Refinement Types: A way to declare a new refinement type based on an existing type and a validation function.
  2. Runtime Validation: A mechanism to check if a given value conforms to a refinement type at runtime.
  3. Type Safety: Ensuring that once a value is validated, it can be safely treated as the refined type within the TypeScript compiler's scope.

Key Requirements:

  • refine<T, P extends Predicate<T>>(baseType: T, predicate: P): A function that "defines" or "registers" a refinement. This function will return a new type that represents the refined type.
  • assert<T>(value: any, refinedType: RefinedType<T, any>): A function that takes a value and a defined refinement type. It should throw an error if the value does not satisfy the predicate of the refinement type. If the value passes, it should return the value typed as the refined type.
  • is<T>(value: any, refinedType: RefinedType<T, any>): A function that takes a value and a refinement type and returns true if the value satisfies the predicate, false otherwise.

Expected Behavior:

  • When assert is called with a valid value, it should return the value.
  • When assert is called with an invalid value, it should throw an error.
  • is should accurately report whether a value meets the refinement criteria.
  • The TypeScript compiler should understand the refined types, allowing for type-safe operations on values that have passed validation.

Edge Cases to Consider:

  • Handling null and undefined within predicates.
  • The type returned by refine should represent the concept of the refined type, not an instance of it.

Examples

Example 1: Positive Number

Imagine we want to define a type for positive numbers.

// Assume this is how you'd define a refinement type conceptually.
// The actual implementation will use a helper.
type PositiveNumber = RefinedType<number, (n: number) => n > 0>;

// Using your system:
const isPositive = (n: number): n > 0 => n > 0;
const PositiveNumberRefined = refine<number>(isPositive); // This would return a special type representing "PositiveNumber"

// Usage
const validValue = 5;
const invalidValue = -2;

try {
  const guaranteedPositive: typeof PositiveNumberRefined.Type = assert(validValue, PositiveNumberRefined);
  console.log("Assertion passed for", guaranteedPositive); // guaranteedPositive is typed as typeof PositiveNumberRefined.Type
} catch (e) {
  console.error(e);
}

try {
  assert(invalidValue, PositiveNumberRefined); // This should throw an error
} catch (e) {
  console.error("Assertion failed as expected:", e.message);
}

console.log("Is 10 positive?", is(10, PositiveNumberRefined)); // true
console.log("Is -5 positive?", is(-5, PositiveNumberRefined)); // false

Example 2: Non-Empty String

Defining a type for strings that are not empty.

// Conceptual definition
// type NonEmptyString = RefinedType<string, (s: string) => s.length > 0>;

// Using your system:
const isNonEmptyString = (s: string): s.length > 0 => s.length > 0;
const NonEmptyStringRefined = refine<string>(isNonEmptyString);

// Usage
const str1 = "hello";
const str2 = "";

try {
  const guaranteedNonEmpty: typeof NonEmptyStringRefined.Type = assert(str1, NonEmptyStringRefined);
  console.log("Assertion passed for:", guaranteedNonEmpty); // guaranteedNonEmpty is typed as typeof NonEmptyStringRefined.Type
} catch (e) {
  console.error(e);
}

try {
  assert(str2, NonEmptyStringRefined); // This should throw an error
} catch (e) {
  console.error("Assertion failed as expected:", e.message);
}

console.log("Is 'world' non-empty?", is("world", NonEmptyStringRefined)); // true
console.log("Is '' non-empty?", is("", NonEmptyStringRefined)); // false

Example 3: String with Minimum Length

// Conceptual definition
// type MinLengthString<L extends number> = RefinedType<string, (s: string) => s.length >= L>;

// Using your system:
const createMinLengthString = <L extends number>(minLength: L) => {
  const predicate = (s: string): s.length >= minLength => s.length >= minLength;
  // The refine function should ideally take a generic parameter for the predicate's return type as well
  return refine<string>(predicate);
};

const MinLength5String = createMinLengthString(5);

// Usage
const shortString = "hi";
const longString = "typescript";

try {
  const guaranteedMinLength: typeof MinLength5String.Type = assert(longString, MinLength5String);
  console.log("Assertion passed for:", guaranteedMinLength);
} catch (e) {
  console.error(e);
}

try {
  assert(shortString, MinLength5String); // This should throw an error
} catch (e) {
  console.error("Assertion failed as expected:", e.message);
}

console.log("Does 'programming' have min length 5?", is("programming", MinLength5String)); // true
console.log("Does 'test' have min length 5?", is("test", MinLength5String)); // false

Constraints

  • Predicate Complexity: Predicate functions can be arbitrarily complex, but they should ideally be pure functions.
  • Base Types: Initially, focus on primitive types like number, string, and boolean. Support for object types can be considered a bonus.
  • Error Messages: The error messages thrown by assert should be informative, indicating which refinement type failed.
  • Performance: While not a primary concern for this initial implementation, the runtime checks should be reasonably efficient. Avoid overly complex or recursive checks that could lead to performance degradation.

Notes

This challenge involves both runtime logic (the predicate checks and assert/is functions) and compile-time type manipulation (defining the RefinedType and ensuring type inference). You'll likely need to use TypeScript's advanced type features, such as conditional types, mapped types, and possibly type guards.

Consider how to associate the predicate function with the refined type in a way that the assert function can access it at runtime, while still allowing TypeScript to infer the correct refined type at compile time. Think about how you can model the RefinedType itself. A common approach is to use a branded type or a nominal typing pattern.

The refine function should return a runtime representation of the refined type that assert and is can use, and simultaneously define a compile-time type that TypeScript can understand. This is the core of the challenge.

Loading editor...
typescript