Search code examples
typescriptcastingreturn-type

Typescript function which casts string value based on predefined datatype for particular keys


EDIT: I have updated working code at bottom of the question.


I'm having a function called get which accepts a key as parameter and returns corresponding value.
This function maintains value (which is always string) and datatype for each key.
Like:

    key         value(stored as string)     dataType
    -------------------------------------------------
    count       '10'                        number
    name        'foo'                       string
    isHoliday   '1'                         boolean
    ===== more rows here =====

Before returning value it casts to specified datatype.

For ex:

  • when key is count, its value '10' is casted to datatype number and value is 10
  • when key is isHoliday, its value '1' is casted to datatype boolean and value is true

The get function returns correct value with correct type.
But return type is not correctly inferred by typescript at funciton called place.const count: number = get('count')
And produces type error like: Type 'string | number | boolean' is not assignable to type 'number'.

Code:


type TypeName = 'number' | 'string' | 'boolean'
type ItemKey = 'count' | 'name' | 'isHoliday'

function get(key: ItemKey) {
    
    /**
     * Predefined data
     */
    const data: {
        [key: string]: {
            value: string
            dataType: TypeName
        }
    } = {
        count       : { value: '10', dataType: 'number' },
        name        : { value: 'foo', dataType: 'string' },
        isHoliday   : { value: '1', dataType: 'boolean' },
        // ===== more items here =====
    }
    
    /**
     * Fetch data based on input
     */
    const value = data[key].value // fetch value
    const dataType = data[key].dataType // fetch TypeName
    
    /**
     * Cast data type and return value
     */
    if(dataType === 'number') {
        return Number(value)
    } else if(dataType === 'string') {
        return String(value)
    } else if(dataType === 'boolean') {
        return value === '1'
    }
}


/**
 * Using get() funciton here
 */
const count: number = get('count')
// Error: Type 'string | number | boolean' is not assignable to type 'number'.

const isHoliday: boolean = get('isHoliday')
// Error: Type 'string | number | boolean' is not assignable to type 'boolean'.

I tried TypeScript: function return type based on argument, without overloading.
But I failed to cast datatype.

interface TypeRegistry {
    count: number
    name: string
    isHoliday: boolean
    // more items here
}

function get<K extends keyof TypeRegistry>(key: K): TypeRegistry[K] {
    const data: {
        [key: string]: {
            value: string
        }
    } = {
        count       : { value: '10' },
        name        : { value: 'foo' },
        isHoliday   : { value: '1' },
        // more items here
    }
    
    const value = data[key].value // fetch value
    
    // How to cast value to TypeRegistry[K]???
    
    return value
    // Error: Type 'string' is not assignable to type 'TypeRegistry[K]'.
}

/**
 * Using get() funciton here
 */
const count: number = get('count')
const isHoliday: boolean = get('isHoliday')

This answer uses conditional types but they are using key to decide datatype.
But in my case number of keys might vary.
I wish something which works using just TypeNames instead of keys.

But no success with this approach also
Code:


type TypeName = 'number' | 'string' | 'boolean'
type ItemKey = 'count' | 'name' | 'isHoliday'

type ReturnDataType<T> =
    T extends 'count' ? number :
    T extends 'name' ? string :
    T extends 'isHoliday' ? boolean:
    // more items here
    never;


function get<T extends ItemKey>(key: ItemKey): ReturnDataType<T> {
    
    /**
     * Predefined data
     */
    const data: {
        [key: string]: {
            value: string
            dataType: TypeName
        }
    } = {
        count       : { value: '10', dataType: 'number' },
        name        : { value: 'foo', dataType: 'string' },
        isHoliday   : { value: '1', dataType: 'boolean' },
        // more items here
    }
    
    /**
     * Fetch data based on input
     */
    const value = data[key].value // fetch value
    const dataType = data[key].dataType // fetch TypeName
    
    /**
     * Cast data type and return value
     */
    if(dataType === 'number') {
        return Number(value) as ReturnDataType<T>
    } else if(dataType === 'string') {
        return String(value) as ReturnDataType<T>
    } else if(dataType === 'boolean') {
        return Boolean(value) as ReturnDataType<T>
    }
}

/**
 * Using get() funciton here
 */
const count: number = get('count')
// Error:
// Type 'string | number | boolean' is not assignable to type 'number'.
//   Type 'string' is not assignable to type 'number'.ts(2322)

const isHoliday: boolean = get('isHoliday')
// Error:
// Type 'string | number | boolean' is not assignable to type 'boolean'.
//   Type 'string' is not assignable to type 'boolean'.ts(2322)




EDIT: From jcalz answer, I came up with following code and it works correctly



/**
 * Data type map
 * Usefull only at compile time
 */
type DataTypeMap = {
    'number': number
    'string': string
    'boolean': boolean
}

/**
 * For typescript type hint (compile time)
 */
type TsTypeRegistry = {
    count       : number
    name        : string
    isHoliday   : boolean
    // ===== more items here =====
}

/**
 * Type required at runtime
 */
const JsTypeRegistry: {
    [key: string]: keyof DataTypeMap
} = {
    count       : 'number',
    name        : 'string',
    isHoliday   : 'boolean',
    // ===== more items here =====
}

/**
 * Convert datatype at runtime
 */
function coerce(value: string, type: keyof DataTypeMap) {
    switch(type) {
        case 'number': return Number(value)
        case 'string': return value
        case 'boolean': return value === '1'
        default: throw new Error(`coerce not implemented for type: '${type}'`)
    }
}

/**
 * The get function
 */
function get<K extends keyof TsTypeRegistry>(key: K): TsTypeRegistry[K] {
    const data = {
        count: { value: '10' },
        name: { value: 'foo' },
        isHoliday: { value: '1' },
        // ===== more items here =====
    }
    
    const value = data[key].value
    const dataType = JsTypeRegistry[key] // datatype required at runtime
    
    return coerce(value, dataType) as TsTypeRegistry[K]
    /**
     * Here 'coerce' converts value at runtime
     * and 'TsTypeRegistry[K]' gives correct type at compile time
     */
}

/**
 * Using get function
 */
const count: number = get('count')
console.log(count, typeof count) // 10 'number'

const someName: string = get('name')
console.log(someName, typeof someName) // foo 'string'

const isHoliday: boolean = get('isHoliday')
console.log(isHoliday, typeof isHoliday) // true 'boolean

/**
 * Now the get function converts data type at runtime as well as
 * gives correct type hint at compile time.
 */


Solution

  • It looks like your various code examples are doing pieces of what you want, but you have not tied it together in a single functioning version. You need to both coerce the types at runtime (as in your first example) and either assert the types at compile time (as in your second example), or you could refactor to a version that the compiler can actually verify the types.


    Neither TypeScript nor JavaScript really has "casting" the way you're thinking about it. In JavaScript it is sometimes possible to coerce a value from one type to another, such as taking a string like "123" and coercing it to a number by using it in an operation that expects a number like +"123". And in TypeScript it is possible to assert that a value will be of a specific type at runtime, but this is just giving more information to the compiler, and has no runtime effect. Both type coercion and type assertions can be thought of as "casting" in some ways, but they are different enough that it's best to avoid ambiguity and not use the term "casting".

    It is very important to realize that type coercion and type assertions are not related to each other at all. No type information that you add to TypeScript will have any effect at runtime; annotating or asserting types only affect what sorts of compiler warnings you see. The entire static type system of TypeScript is erased when TypeScript code is compiled to JavaScript. If you want a value to be of a particular type at runtime, you will need to write some code to make that happen.

    If the compiler cannot figure out that what you are doing is type safe, such as in your first example where string | number | boolean is not seen as assignable to TypeRegistry[K], you can use a type assertion or something similar to suppress the compiler error. For a situation like yours where a function has multiple return lines and none of them type check, I usually write the function as an overloaded function with a single call signature. The call signature is strongly typed enough to allow callers to get different output types for different input types, while the implementation signature is loosely typed enough to allow string | number | boolean return values:

    interface TypeRegistry {
      count: number
      name: string
      isHoliday: boolean
      // more items here
    }
    
    // call signature
    function get<K extends keyof TypeRegistry>(key: K): TypeRegistry[K];
    
    // implementation
    function get(key: keyof TypeRegistry): TypeRegistry[keyof TypeRegistry] {
      const data = {
        count: { value: '10', dataType: 'number' },
        name: { value: 'foo', dataType: 'string' },
        isHoliday: { value: '1', dataType: 'boolean' },
        // ===== more items here =====
      }
    
      const value = data[key].value // fetch value
      const dataType = data[key].dataType // fetch TypeName
    
      if (dataType === 'number') {
        return Number(value);
      } else if (dataType === 'string') {
        return String(value);
      } else if (dataType === 'boolean') {
        return value === '1';
      }
      throw new Error()
    }
    

    You can verify that this works as desired. Note that an overload or a type assertion require care on your part; the compiler cannot verify the type safety of the implementation, and we have not changed this by using an overload. All we've done is told the compiler not to worry about it. If we make a mistake (say by switching around the dataType === 'number' check and the dataType === 'string' check), the compiler won't notice and you'll see problems at runtime.


    If you want the compiler to actually verify type safety without requiring assertions or overloads, you can refactor to something like this:

    const coerce = (v: string) => ({
      get count() {
        return +v
      },
      get name() {
        return v
      },
      get isHoliday() {
        return v === "1"
      }
    })
    type TypeRegistry = ReturnType<typeof coerce>;
    
    function get<K extends keyof TypeRegistry>(key: K): TypeRegistry[K] {
      const data = {
        count: { value: '10' },
        name: { value: 'foo' },
        isHoliday: { value: '1' },
        // more items here
      }
      return coerce(data[key].value)[key];
    }
    
    /**
     * Using get() funciton here
     */
    const count: number = get('count');
    console.log(typeof count); // "number"
    const isHoliday: boolean = get('isHoliday')
    console.log(typeof isHoliday) // "boolean"
    

    We are still coercing the values at runtime, but now the compiler can follow what we're doing. This works because the output of the coerce() function is seen as a value of type TypeRegistry... it happens to be implemented with getters, so that only one coercion code path is actually called. And the compiler does understand that indexing into a value of type TypeRegistry with a key of type K will produce a value of type TypeRegistry[K].

    Playground link to code