How do I correctly type a function that checks whether a key exists in object, and returns the corresponding value?

Viewed 73

I held off a long while thinking I would come across the answer, but I still haven't so I'm giving SO a shot

import { PDFViewer, MSViewer } from './viewerclasses'

//tried adding in just a union of the keys
type ViewerTypes = 'xls' | 'xlsx' | 'doc' | 'docx' | 'pdf';

type PreviewTypes = {
    pdf: typeof PDFViewer;
    doc: typeof PDFViewer;
    docx: typeof PDFViewer;
    xls: typeof MSViewer;
    xlsx: typeof MSViewer;
}


const previewTypes: PreviewTypes = {
    pdf: PDFViewer,
    doc: PDFViewer,
    docx: PDFViewer,
    xls: MSViewer,
    xlsx: MSViewer
};

//attempt #1
type ViewerMap<T> = T extends ViewerTypes ? PreviewTypes[T] : false;
//attempt #2
type ViewerMaybe<T> = T extends keyof PreviewTypes ? PreviewTypes[T] : false

export function getViewer<K extends ViewerTypes>(filename: K): ViewerMaybe<typeof filename> {

    const type = (filename.split('.').pop()?.toLowerCase() as ViewerTypes) || 'unknown';

    const viewer = Object.prototype.hasOwnProperty.call(previewTypes, type) === true
    ? previewTypes[type]
    : false;

    return viewer;
}

But I'm just shooting in the dark here, trying to give getViewer() different types, mapped types, indexed access types, etc, etc, but TS still doesn't really know what I'm trying to do.

I'm trying to type getViewer() correctly, so that Typescript knows if I hand in a key of previewTypes as an argument, I get a constructor back, and if not, I get false back. I've brushed over this problem for so long, but I would like to understand the type system enough to tackle it. I know there's a way to create an indexed access type that says something like

type ViewerIndexMap<T> = {
[Prop in keyof T]: Prop in keyof T ? T[Prop] : false 
}

and then,

export function getViewer(filename): ViewerIndexMap<typeof filename>

or something like that

Where am I going wrong? What am I missing? I just re-read the TS handbook, and while I feel like mapped types are close, they don't get me exactly where I need to be.

Thank you!

1 Answers

Even though learning about mapped types is always a good plan, you don't actually need them here. By just creating a non-generic function that does a lookup in previewTypes, the correct result type can be inferred.

First, you can simplify the types a bit by deriving them from the previewTypes:

const previewTypes = {
    pdf: PDFViewer,
    doc: PDFViewer,
    docx: PDFViewer,
    xls: MSViewer,
    xlsx: MSViewer
} 

type PreviewTypes = typeof previewTypes
// evaluates to { pdf: typeof PDFViewer, .., xlsx: typeof MSViewer }
type ViewerTypes = keyof PreviewTypes
// evaluates to "pdf" | "doc" | "docx" | "xls" | "xlsx"

For the lookup function I would use something like

export function getViewer(filename: string) {
  const ext = filename.split('.').pop()?.toLowerCase()

  return ext !== undefined && previewTypes.hasOwnProperty(ext)
         ? previewTypes[ext as ViewerTypes]
         : false
}

Because TypeScript cannot infer that ext is a key of previewTypes from the hasOwnProperty(ext) in the condition, there's a cast ext as ViewerTypes at the previewTypes index. It's best to avoid as whenever possible, but in cases where the code is obviously correct, and it's a big nuisance to do it type safe, it can be acceptable.

The inferred return type for getViewer is false | typeof PDFViewer | typeof MSViewer but you could also explicitly specify the signature false | PreviewTypes[ViewerTypes] (where PreviewTypes[ViewerTypes] is an indexed access type).

Playground Link

UPDATE: It turns out it's not too difficult after all to infer that ext is a key of previewTypes. With the following hasOwnProperty function (see this TypeScript issue)

function hasOwnProperty<T extends object>(o: T, v: PropertyKey): v is keyof T {
  return o.hasOwnProperty(v)
}

you can rewrite the return statement as

  return ext !== undefined && hasOwnProperty(previewTypes, ext)
         ? previewTypes[ext] : false

Playground Link

It is probably worth noting that although clearer, the code is still not type safe. By using is you tell TypeScript to trust that the result of hasOwnProperty correctly establishes whether the argument is a valid key. If you were to use the incorrect return !o.hasOwnProperty(v) for example, this would not yield a type error.

Related