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>>>
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