expect-type

repository·main·Indexed 20 days ago

https://github.com/mmkal/expect-type

A library for performing compile-time type assertions in TypeScript. It provides a Jest-inspired fluent API to ensure types remain correct and prevent regressions into overly permissive types. Key features include strict equality checks with .toEqualTypeOf(), subset matching with .toMatchObjectType(), function signature inspection via .parameter() and .returns, and tools to detect hidden 'any' or 'never' types using .branded.inspect().

Tokens
6.7K
Snippets
27
Records
30
Agent score
69%

What's inside expect-type

  1. Overview of expect-type

    main
    expect-type provides compile-time tests for TypeScript types. It is designed to prevent types from regressing into being overly permissive over time. It functions similarly to runtime assertion libraries like expect, but is specifically built for type-awareness, allowing you to make assertions about the form of references or generic type parameters.
  2. Compare expect-type with other type assertion tools

    main

    Compared to other projects like tsd, ts-expect, or type-plus, expect-type provides:

    • Fluent API: A Jest-inspired syntax that clearly distinguishes between actual and expected types.
    • Intuitive Inversion: Easily negate assertions using .not (e.g., expectTypeOf(...).not.toBeAny()).
    • Strict Generics: Proper and strict handling of generic type checks.
    • First-class Support: Specialized support for any, unknown, and never, as well as object properties, function parameters/returns, constructor parameters, class instances, array items, and nullable types.
    • Relationship Matching: Support for "is-a" relationships using .toExtend<T>() rather than just exact equality.
    • Zero Tooling Overhead: No CLI, IDE extensions, or extra build steps required. Assertions are checked at compile time via tsc and appear directly in your IDE.
    • Lightweight: A small implementation with no dependencies.
  3. Use expectTypeOf for compile-time type assertions

    main

    Import expectTypeOf from expect-type to perform type-aware assertions. Unlike runtime assertions, these failures occur at compile time and will appear in your IDE or when running tsc.

    Common patterns include:

    • Checking if a value matches a specific object shape using .toEqualTypeOf<T>().
    • Inspecting function parameters using .parameter(index).
    • Inspecting function return types using .returns.
    • Using .not to negate assertions.
    import {expectTypeOf} from 'expect-type'
    import {foo, bar} from '../foo'
    
    // make sure `foo` has type {a: number}
    expectTypeOf(foo).toEqualTypeOf<{a: number}>()
    
    // make sure `bar` is a function taking a string:
    expectTypeOf(bar).parameter(0).toBeString()
    expectTypeOf(bar).returns.not.toBeAny()
  4. Use DeepBrand for strict type equality checking

    main

    The DeepBrand<T, Options> type recursively transforms a type T into a branded representation. This is used to perform equality checks that are stricter than standard TypeScript extends checks. Specifically, it can distinguish between edge cases that vanilla TypeScript often treats as compatible, such as:

    • any vs unknown
    • { readonly a: string } vs { a: string } (readonly vs mutable)
    • { a?: string } vs { a: string | undefined } (optional vs explicit undefined)

    Note: DeepBrand is not highly performant for complex types. If you are performing a standard equality check, it is generally better to use StrictEqualUsingTSInternalIdenticalToOperator (if available in your context).

    export type DeepBrand<T, Options extends DeepBrandOptions> = ...
  5. Use .branded for intersection types and complex identities

    main

    Intersection types (e.g., { a: 1 } & { b: 2 }) can cause toEqualTypeOf() to fail even if they are logically equivalent to a single object type.

    Use .branded to perform a more permissive but less performant check that accommodates for equivalent intersection types.

    // This might fail with standard toEqualTypeOf
    expectTypeOf<{ a: 1 } & { b: 2 }>().branded.toEqualTypeOf<{ a: 1; b: 2 }>()
  6. Configure ESLint for Jest and expectTypeOf

    main

    If you use Jest with eslint-plugin-jest, you may see jest/expect-expect warnings because expectTypeOf does not look like a standard Jest assertion. To fix this, add expectTypeOf to the assertFunctionNames option in your ESLint configuration.

    "rules": {
      "jest/expect-expect": [
        "warn",
        {
          "assertFunctionNames": [
            "expect", "expectTypeOf"
          ]
        }
      ]
    }
  7. Understand error messages in expect-type

    main

    Because assertions are written fluently, error messages can sometimes appear to be describing the 'expected' type rather than the 'actual' type.

    For .toEqualTypeOf / .toMatchTypeOf: If you see a message like Type 'string' is not assignable to type '"Expected: string, Actual: number"', do not take it literally. Look at the property name and the human-readable string inside the quotes. It is telling you: Expected: string, Actual: number.

    For .toBe... methods: These fail by resolving to a non-callable type. An error like Type 'ExpectString<number>' has no call signatures means you asserted a number should be a string.

    Best Practice: To get the clearest error messages, use type arguments instead of concrete objects whenever possible:

    • Better: expectTypeOf({a: 1}).toEqualTypeOf<{a: string}>()
    • Worse: expectTypeOf({a: 1}).toEqualTypeOf({a: ''})

    If you must compare two concrete values, use typeof: expectTypeOf(one).toEqualTypeOf<typeof two>()

  8. Protect against permissive types using .not.toBeAny()

    main

    You can use expect-type to ensure functions do not return overly permissive types like any. This is useful when refactoring code to move away from any towards safer types.

    Example scenario: ensuring a parsing function returns a specific type instead of any.

    // Example of protecting against permissive types
    expectTypeOf(parseFile).returns.not.toBeAny();
  9. Assert function parameters and return values

    main

    For functions, you can drill down into specific parts of the signature:

    • .parameters: Returns a union of all parameter arrays (up to 10 overloads).
    • .parameter(n): Targets a specific parameter by index.
    • .returns: Targets the return type.
    • .resolves: Used with Promises to check the resolved value type.
    • .toBeCallableWith(...args): Asserts that a function can be called with specific arguments. It also returns a type that allows you to narrow down the return type based on those arguments.

    Note: For overloaded functions, expect-type provides better support than native TypeScript Parameters<T> by returning a union of all overloads.

    type Factorize = {
      (input: number): number[]
      (input: bigint): bigint[]
    }
    
    // Check all overloads
    expectTypeOf<Factorize>().parameters.toEqualTypeOf<[number] | [bigint]>()
    
    // Narrow return type based on input
    expectTypeOf<Factorize>().toBeCallableWith(6).returns.toEqualTypeOf<number[]>()
    
    // Check Promise resolution
    expectTypeOf(Promise.resolve(123)).resolves.toBeNumber()
  10. Compare object types with .toEqualTypeOf, .toMatchObjectType, and .toExtend

    main

    Use these three methods to assert object relationships, but choose the one that matches your intent:

    • .toEqualTypeOf<T>(): Performs a strict equality check. The types must be identical. It fails if there are excess properties or missing properties.
    • .toMatchObjectType<T>(): Performs a strict check on a subset of keys. It succeeds if the object contains at least the keys defined in T, even if it has extra properties. This is usually preferred for object testing.
    • .toExtend<T>(): Checks for an "is-a" relationship. It succeeds if the actual type is a subtype of the expected type.

    Note: All three methods fail if properties defined in the expected type are missing from the actual type.

    // Strict equality (fails on excess properties)
    expectTypeOf({a: 1, b: 1}).toEqualTypeOf<{a: number}>()
    
    // Partial match (allows extra properties)
    expectTypeOf({a: 1, b: 1}).toMatchObjectType<{a: number}>()
    
    // Subtype check (is-a relationship)
    type Fruit = {type: 'Fruit'; edible: boolean}
    type Apple = {type: 'Fruit'; name: 'Apple'; edible: true}
    expectTypeOf<Apple>().toExtend<Fruit>()
  11. Find hidden 'any' or 'never' types with .branded.inspect

    main

    When you have a large, complex object and don't know where a bad type (like any or never) is lurking, use .branded.inspect(). This performs a deep walk of the type and reports the paths to the problematic types.

    Usage:

    • Use it to find any or never (default).
    • You can specify findType: 'unknown' to search for unknown types.
    • Warning: This is a heavy operation. It can cause the TypeScript compiler to give up if the type is excessively deep. Use it for debugging or validation, but avoid committing it to source control if possible.

    Comparison:

    • Use .toBeAny() when you know exactly which property should be any.
    • Use .branded.inspect() when you are searching for a needle in a haystack.
    const bad = (metadata: string) => ({
      meta: {
        parsed: JSON.parse(metadata), // any!
      },
    })
    
    // Finds the path to the 'any' type
    expectTypeOf(bad).returns.branded.inspect({
      foundProps: {
        '.meta.parsed': 'any',
      },
    })