Skip to content

Record<string, never> branch is not recognized on condition over object #47071

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
javiertury opened this issue Dec 8, 2021 · 3 comments
Closed
Labels
Question An issue which isn't directly actionable in code

Comments

@javiertury
Copy link

Bug Report

🔎 Search Terms

Record<string, never>
object condition
Record<string, never> branch is not recognized on condition over object

🕗 Version & Regression Information

  • This is the behavior in every version I tried, from 3.3.3 to 4.5.2 and nightly

⏯ Playground Link

Playground link with relevant code

💻 Code

type Config <T extends object> =
  & { mandatory: number }
  & (T extends Record<string, never>
    ? { custom?: undefined }
    : { custom: T });

const fn = <T extends object = Record<string, never>>(config: Config<T>) => {
    const custom = config.custom; // Wrong, typescript is not aware that it can be undefined

    // Do something
    console.log(Object.keys(custom));
}

fn({ mandatory: 1 }) // Broken, `config.custom` can be undefined

fn({ mandatory: 1, custom: { a: 'a' } }) // Works

🙁 Actual behavior

Typescript says that the variable const custom inside fn() is of type object, but it's not true, it can be undefined. Running the code throws an error, even though typescript doesn't see anything wrong with code.

🙂 Expected behavior

T variable const custom inside fn() should be of type object | undefined.

@RyanCavanaugh
Copy link
Member

Record<string, never> describes a type that, if you accessed any of its properties, you'd get an exception.

What are you trying to do here?

@RyanCavanaugh RyanCavanaugh added the Question An issue which isn't directly actionable in code label Dec 9, 2021
@javiertury
Copy link
Author

javiertury commented Dec 9, 2021

First I wan to clarify that this bug is not critical for me. I just thought that I should report it.

I'm using some configuration parameters that have type object. Within the space covered by object, I'm trying to use Record<string, never> as a special identifier to detect when a type argument can be optional.

Let's say I have a system of layered composition. These layers may or may not have dependencies (on other layers), and may or may not require the use of some caches (shared among layers).

/*
 * Base class
 */

type LayerConfig <
  D extends object,
  C extends object
> =
  & { context: Context }
  & (D extends Record<string, never>
    ? { dependencies?: undefined }
    : { dependencies: D })
  & (C extends Record<string, never>
    ? { cache?: undefined }
    : { cache: C });

class Layer <
  D extends object = Record<string, never>,
  C extends object = Record<string, never>
> {
  dependencies: D;
  cache: C;

  constructor(config: LayerConfig<D, C>) {
    // Careful, typescript thinks they will never be undefined, but they can be
    this.dependencies = config.dependencies ?? {};
    this.cache = config.cache ?? {};
    
    this.registerCaches(this.cache);
  }
  
  beforeDestroy () {
    this.unregisterCaches(this.cache);
  }
}

This base class is extended by all layers. When needed, I declare the required dependencies and cache and typescript checks they are satisfied during initialization.

interface RestaurantDependencies {
  listing
  place
  map
}

interface RestaurantCache {
  mapObjects
}

class RestaurantLayer extends Layer<RestaurantDependencies, RestaurantCache> {
  async searchRestaurants () {
    const results = await this.dependencies.listing.search({ category: 'RESTAURANT' });
    
    results.forEach(result => {
      const { coordinates } = this.dependencies.place.getById(result.placeId);
      this.dependencies.map.drawLocation(
        {
          id result.id,
          latitude: coordinates.latitude,
          longitude: coordinates.longitude,
          description: result.name
        },
        this.cache.mapObjects
      );
    });
  }
}

const restaurant = new RestaurantLayer({ context, dependencies, cache })

Others will be simpler and don't need any argument.

class StatusLayer extends Layer {
  async printStats () {
    return this.context.sideInfo.replace(this.context.stats);
  }
}

// No need to pass properties that won't be used
const status = new StatusLayer({ context })

status.printStats();

I know some alternatives, like declaring the configurations parameters with type object | undefined and using undefined to detect when a type argument can be optional. However this alternative increases the complexity everywhere else in the codebase. For example, let's say I want to merge the dependencies of two layers.

// Using `object` for general and `Record<string, never>` for empty
type MergeDeps <A extends object, B extends object> = A & B;

// Using `object | undefined` for general and `undefined for empty
type MergeDeps <A extends object | undefined, B extends object | undefined> = A extends object
  ? B extends object
    ? A & B
    : A
  : undefined

@typescript-bot
Copy link
Collaborator

This issue has been marked as 'Question' and has seen no recent activity. It has been automatically closed for house-keeping purposes. If you're still waiting on a response, questions are usually better suited to stackoverflow or the TypeScript Discord community.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Question An issue which isn't directly actionable in code
Projects
None yet
Development

No branches or pull requests

3 participants