1

Background: I am working on a game and I want the physics engine to be exchangeable, so each engine implementation has to be wrapped into a common interface. For instance, each engine has to implement the following function:

createCircle(position, radius = 1)

but let's generalize and go with frobnicate which needs two required parameters and two optional parameters.

Here would be my ideal which is not possible in TypeScript:

interface FrobCfg {
    reqA: number
    reqB: number
    optC?: number = 13
    optD?: number = 37
}
type FrobnicateFn = (cfg: FrobCfg) => void

and then an engine implements:

let frobnicate:FrobnicateFn = function(cfg: FrobCfg) {
    console.log(cfg.reqA, cfg.reqB, cfg.optC, cfg.optD)
}
frobnicate({reqA:1, reqB:2, optC:3})

Note that the default values are actually already known and should be consistent across all engines. Many approaches do not consider this.


Edit2: Here is the version I am now going with:

// Interface
//----------
interface FrobCfg{
    reqA:number
    reqB:number
    optC?:number
    optD?:number
}
const frobDefaults:Optionals<FrobCfg> = {
    optC:13,
    optD:37
}
type Frobnicator = (cfg: FrobCfg) => void

// Implementation
//---------------
const frobnicate:Frobnicator = (cfg: FrobCfg) => {
    const {reqA, reqB, optC, optD}:Required<FrobCfg> =
        {...frobDefaults, ...cfg}

    console.log(reqA, reqB, optC, optD)
}

// Call
//-----
frobnicate({reqA:1, reqB:2, optC:3})


// Generics Utilities
// https://stackoverflow.com/a/49579497/3825996

type IfEquals<X, Y, A=X, B=never> =
  (<T>() => T extends X ? 1 : 2) extends
  (<T>() => T extends Y ? 1 : 2) ? A : B;

type RequiredKeys<T> = { [K in keyof T]-?:
  ({} extends { [P in K]: T[K] } ? never : K)
}[keyof T]

type OptionalKeys<T> = { [K in keyof T]-?:
  ({} extends { [P in K]: T[K] } ? K : never)
}[keyof T]

type Optionals<T> = Required<Pick<T, OptionalKeys<T>>>

TypeScript Playground


Here is everything I've tried in order to approximate the original ideal. The function type definition has been omitted for brevity.


// 0) the obvious way:
//--------------------
function frobnicate0(reqA: number, reqB: number, optC: number = 13, optD: number = 37) {
    console.log(reqA, reqB, optC, optD)
}
frobnicate0(1, 2, 3)
// + super short
// - parameters are not named in call
// - order matters
// - optional parameters cannot be skipped
// - consistency of default values not enforced across implementations


// 1) the common way:
//-------------------
interface Frob1Cfg {
    reqA: number
    reqB: number
    optC?: number
    optD?: number
}
function frobnicate1(cfg: Frob1Cfg) {
    let optC = cfg.optC || 13
    let optD = cfg.optD || 37
    console.log(cfg.reqA, cfg.reqB, optC, optD)
}
frobnicate1({reqA:1, reqB:2, optC:0})
// + pretty short
// - || does not really convey intent to beginners
// - consistency of default values not enforced across implementations
// - fails unacceptably on falsy arguments!!!


// 2) the elaborate way:
//----------------------
interface Frob2Cfg {
    reqA: number
    reqB: number
    optC?: number
    optD?: number
}
function frobnicate2(cfg: Frob2Cfg) {
    let optC = typeof(cfg.optC) !== "undefined"? cfg.optC : 13
    let optD = typeof(cfg.optD) !== "undefined"? cfg.optD : 37
    console.log(cfg.reqA, cfg.reqB, optC, optD)
}
frobnicate2({reqA:1, reqB:2, optC:0})
// + rock-solid
// - very elaborate and un-DRY-esque
// - if multiple functions want a Frob2Cfg, it's extra repeating
// - consistency of default values not enforced across implementations


// 3) another idea:
//-----------------
class Frob3Cfg{
    reqA: number
    reqB: number
    optC: number = 13
    optD: number = 37
    constructor(cfg: Pick<Frob3Cfg, 'reqA' | 'reqB'> & Partial<Frob3Cfg>){
        Object.assign(this, cfg)
    }
}
function frobnicate3(cfg: Frob3Cfg){
    console.log(cfg.reqA, cfg.reqB, cfg.optC, cfg.optD)
}
frobnicate3(new Frob3Cfg({reqA:1, reqB:2, optC:0}))
// - TypeScript complains that reqA and reqB are not assigned
// - the cfg type in the constructor is somewhat complicated
// + if all parameters are optional it's ok
// - call is elaborate
// + the function can be sure it gets a full cfg which looks very clean
// + and scales very well if more functions require a Frob3Cfg
// + the cfg object can be reused and is completed with defaults
//   only once and not with every function call
// - ts tooltip when writing a call is unhelpful


// 4) yet another one:
//--------------------
interface Frob4Cfg{
    reqA: number
    reqB: number
    optC?: number
    optD?: number
}
function fillFrob4Cfg(cfg:Frob4Cfg):Required<Frob4Cfg>{
    return Object.assign({}, {optC:13, optD:37}, cfg)
}
function frobnicate4(cfg:Frob4Cfg){
    let fullcfg = fillFrob4Cfg(cfg)
    console.log(fullcfg.reqA, fullcfg.reqB, fullcfg.optC, fullcfg.optD)
}
frobnicate4({reqA:1, reqB:2, optC:3})
// + short function definition, short call
// - extra function necessary
// - defaults are copied and overwritten
// - default values somewhat hidden


// 5) awesome ES6 way I found after some googling:
//------------------------------------------------
interface Frob5Cfg{
    reqA: number
    reqB: number
    optC?: number
    optD?: number
}
function frobnicate5({reqA, reqB, optC=13, optD=37}:Frob5Cfg){
    console.log(reqA, reqB, optC, optD)
}
frobnicate5({reqA:1, reqB:2, optC:3})
// - nothing forces the implementer of frobnicate to consider optC and optD
// - consistency of default values not enforced across implementations


// 6) next try:
//-------------
type Optional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>
type OptionalExcept<T, K extends keyof T> = Pick<T, K> & Partial<Omit<T, K>>
type AllOptional<T> = Partial<T> // for consistency

class Frob6Cfg{
    reqA:number
    reqB:number
    optC:number
    optD:number
    constructor({reqA, reqB, optC = 13, optD = 37}:Optional<Frob6Cfg, 'optC' | 'optD'>){
        this.reqA = reqA
        this.reqB = reqB
        this.optC = optC
        this.optD = optD
    }
}
function frobnicate6(cfg: Frob6Cfg){
    console.log(cfg.reqA, cfg.reqB, cfg.optC, cfg.optD)
}
frobnicate6(new Frob6Cfg({reqA:1, reqB:2, optC:0}))
// - lot of repetition
// - function call elaborate
// + solves everything else


// 7) again with factory:
//-----------------------

// (already defined above)
// type Optional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>
// type OptionalExcept<T, K extends keyof T> = Pick<T, K> & Partial<Omit<T, K>>
// type AllOptional<T> = Partial<T> // for consistency

interface Frob7Cfg{
    reqA:number
    reqB:number
    optC:number
    optD:number
}
function defaultFrob7({
    reqA,
    reqB,
    optC = 13,
    optD = 37
}:Optional<Frob7Cfg, 'optC' | 'optD'>):Frob7Cfg{
    return arguments[0]
}
function frobnicate7(cfg: Frob7Cfg){
    console.log(cfg.reqA, cfg.reqB, cfg.optC, cfg.optD)
}
frobnicate7(defaultFrob7({reqA:1, reqB:2, optC:3}))
// - still somewhat elaborate
// - typescript thinks the arguments of defaultFrob7 are not used

// EDIT:

// 8) adapted suggestion by ritaj:
//--------------------------------
interface Frob8Cfg{
    reqA:number
    reqB:number
    optC?:number
    optD?:number
}
const frob8defaults:Optionals<Frob8Cfg> = {
    optC:13,
    optD:37
}
function frob8defaultizer(impl:(fullCfg:Required<Frob8Cfg>) => void){
    return function(halfCfg:Frob8Cfg){
        const filledCfg:Required<Frob8Cfg> = {...frob8defaults, ...halfCfg}
        return impl(filledCfg)
    }
}
const frobnicate8 = frob8defaultizer((cfg: Required<Frob8Cfg>)=>{
    console.log(cfg.reqA, cfg.reqB, cfg.optC, cfg.optD)
})
// - I find the function builder super confusing


// 9) solution inspired by ritaj and this
// https://stackoverflow.com/a/49579497/3825996
//---------------------------------------------
interface Frob9Cfg{
    reqA:number
    reqB:number
    optC?:number
    optD?:number
}
let frob9defaults:Optionals<Frob9Cfg> = {optC:13, optD:37}

function frobnicate9(cfg: Frob9Cfg){
    let fullCfg:Required<Frob9Cfg> = {...frob9defaults, ...cfg}
}
frobnicate9({reqA:1, reqB:2, optC:3})
// -+ defaults not enforced but easily kept consistent


// credits to
// https://stackoverflow.com/a/49579497/3825996
type IfEquals<X, Y, A=X, B=never> =
  (<T>() => T extends X ? 1 : 2) extends
  (<T>() => T extends Y ? 1 : 2) ? A : B;

type RequiredKeys<T> = { [K in keyof T]-?:
  ({} extends { [P in K]: T[K] } ? never : K)
}[keyof T]

type OptionalKeys<T> = { [K in keyof T]-?:
  ({} extends { [P in K]: T[K] } ? K : never)
}[keyof T]

type Requireds<T> = Pick<T, RequiredKeys<T>>
type Optionals<T> = Required<Pick<T, OptionalKeys<T>>>

All of these approaches have some shortcomings. I am now pretty satisfied with the latest approach. But still open for suggestions! Is there any ultimately beautiful way I have not considered? TypeScript Playground

Mircode
  • 432
  • 5
  • 12

1 Answers1

1

Hmm, maybe you could try to create a function builder which will add default parameters for the function passed from elsewhere:

interface FooParams {
    a: number;
    b: string;
}

type FooParamsPartial = Partial<FooParams>;

type FooFunction = (args: FooParams) => void

const fooFun: FooFunction = (args: FooParams) => {

}

const defaults: FooParams = {
    a: 1,
    b: 'b'
}
const builder = (fooFun: FooFunction): FooFunction => {
    const withDefault = function (args: FooParams) {
        const newArgs = { ...defaults, ...args };
        return fooFun(newArgs);
    }

    return withDefault;
}

const withDefault = builder(fooFun);
Roberto Zvjerković
  • 9,657
  • 4
  • 26
  • 47
  • Thank you! I have tried to adapt and shorten your solution to my scenario for better comparison. I find the function builder a bit confusing. But I was not aware of the {...defaults, ...args} thing! That simplifies a lot! – Mircode May 24 '20 at 19:39